Skip to content

Relay spend guards: per-IP rate limits, hard daily budget cap, structured logs#715

Merged
arul28 merged 6 commits into
mainfrom
ade/relay-spend-guards
Jul 7, 2026
Merged

Relay spend guards: per-IP rate limits, hard daily budget cap, structured logs#715
arul28 merged 6 commits into
mainfrom
ade/relay-spend-guards

Conversation

@arul28

@arul28 arul28 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Cloudflare has no native hard billing cap, so this enforces spend + abuse controls in the workers themselves. Already deployed to production (both workers redeployed, migration applied to remote D1, live smoke-tested); this PR lands the code in main so the next deploy-from-main keeps the protections.

push-relay

  • Closes the unauthenticated-write gap on /claim: per-IP rate limit (default 10/min) + a general per-IP limit on all routes (default 120/min), backed by a new rate_counters D1 table. Fixed-window; rejects read-only once a window is over, so the limiter never amplifies the spend it exists to bound.
  • Hard daily request budget (default 1,000,000/day — sized so a full month at the cap stays well under ~$10 of Cloudflare overage) with an in-isolate latch: once blown, further requests reject for free until midnight UTC. All three limits are wrangler.jsonc vars — tunable without a code change.
  • Structured JSON logs: rate_limited / budget_exceeded / auth_failed / apns_error / claim_conflict, plus observability enabled for persistent, queryable history.

tunnel-relay

  • Lifecycle logging (host_registered / connect_rejected / auth_failed) + observability. Its cost was already gated (signed upgrades, single control socket, max-tunnels cap, idle sweep), so no new limiter was needed.

Verification

  • push-relay: 11 tests (limit trips, budget trips, per-IP isolation, /health bypass); tunnel-relay: 15 tests. Both typecheck clean.
  • Live prod smoke: 10 /claim requests pass, then 429 — the limit trips exactly at the configured value; /health still apnsConfigured:true.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added configurable request limits to improve abuse protection and control usage.
    • Added structured observability logs for key relay and tunnel events, making issues easier to trace in the dashboard and log stream.
  • Bug Fixes

    • Improved handling of unauthorized, conflict, and APNs failure cases with clearer logged outcomes.
    • Ensured health checks continue to respond normally without being blocked by request limits.
  • Documentation

    • Expanded operational docs for rate limiting, budget controls, and logging visibility.

Greptile Summary

This PR adds spend and abuse controls to the relay workers. The main changes are:

  • D1-backed per-IP and /claim request limits for push-relay.
  • A daily push-relay request budget with an in-isolate latch.
  • Scheduled pruning for relay state and rate counter rows.
  • Structured JSON logs and observability settings for both relay workers.

Confidence Score: 5/5

This looks safe to merge.

No blocking issues found in the changed code.

T-Rex T-Rex Logs

What T-Rex did

  • Typecheck completed with exit code 0.
  • Unit tests ran and all 13 tests passed.
  • Smoke test results observed: health endpoint returned 200, claim requests 1–10 returned 400, claim request 11 returned 429, and health remained 200 afterwards.
  • A status summary artifact was prepared to summarize the observed HTTP status codes.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
apps/push-relay/src/relay.ts Adds request budget checks, rate-limit gates, pruning for counter rows, and structured push-relay logs.
apps/push-relay/src/index.ts Adds the scheduled handler that runs relay-state pruning.
apps/push-relay/wrangler.jsonc Adds observability, cron scheduling, and configurable spend-control variables.
apps/tunnel-relay/src/tunnelDo.ts Adds structured tunnel lifecycle and rejection logs.

Comments Outside Diff (1)

  1. apps/push-relay/src/relay.ts, line 603-613 (link)

    P2 Claim Traffic Leaves Stale Counters

    The cleanup for non-budget rate_counters rows only runs from pruneRelayState, while the new unauthenticated /claim limiter creates ip:* and claim:* rows without reaching that cleanup path. A rotating-IP claim flood can leave stale counter rows in D1 until some later device upsert or publish happens to prune them, turning the abuse path into persistent table growth.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/push-relay/src/relay.ts
    Line: 603-613
    
    Comment:
    **Claim Traffic Leaves Stale Counters**
    
    The cleanup for non-budget `rate_counters` rows only runs from `pruneRelayState`, while the new unauthenticated `/claim` limiter creates `ip:*` and `claim:*` rows without reaching that cleanup path. A rotating-IP claim flood can leave stale counter rows in D1 until some later device upsert or publish happens to prune them, turning the abuse path into persistent table growth.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Reviews (6): Last reviewed commit: "review: no D1 write on rate-limit reject..." | Re-trigger Greptile

…ured logs

Cloudflare has no native hard billing cap, so enforce spend + abuse controls
in the workers themselves.

push-relay:
- Close the unauthenticated-write gap: per-IP rate limit on /claim (default
  10/min) plus a general per-IP limit on all routes (default 120/min), backed
  by a new rate_counters D1 table (fixed-window; rejects read-only once over,
  so the limiter never amplifies the spend it bounds).
- Hard daily request budget (default 1,000,000/day ≈ well under $10/mo of
  overage) with an in-isolate latch so a blown budget rejects for free until
  midnight UTC. All three limits are wrangler vars — tune without a code change.
- Structured JSON logs for rate_limited / budget_exceeded / auth_failed /
  apns_error / claim_conflict; observability enabled for persistent history.

tunnel-relay:
- Lifecycle logging (host_registered / connect_rejected / auth_failed) +
  observability. Its cost was already gated (signed upgrades, one control
  socket, max-tunnels, idle sweep) so no new limiter needed.

Tests: 11 push-relay (limit trips, budget trips, per-IP isolation, health
bypass) + 15 tunnel-relay, both typecheck clean. Migration applied to remote
D1 and both workers redeployed; live smoke confirms /claim 429s at the limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview Jul 7, 2026 4:57am

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds spend and abuse controls to the push-relay worker: a new rate_counters D1 table, a fixed-window per-IP/claim rate limiter, and a daily request budget with an in-isolate latch. It also adds structured JSON logging events (auth_failed, apns_error, claim_conflict, rate_limited, budget_exceeded) across push-relay, and lifecycle logging (host_registered, connect_rejected, auth_failed) in tunnel-relay. Wrangler configs enable observability, and both READMEs document the new behavior. Tests extend the fake D1 harness and add coverage for rate limiting and budget enforcement.

Changes

Push-relay spend controls and logging

Layer / File(s) Summary
Rate counter schema and env config
apps/push-relay/migrations/0002_rate_and_budget.sql, apps/push-relay/src/relay.ts
Adds the rate_counters table with an updated_at index, and new PushRelayEnv config fields/defaults for daily budget and per-IP/claim rate limits.
Rate limiter and budget guard implementation
apps/push-relay/src/relay.ts
Implements a D1-backed fixed-window rate limiter, a daily budget counter with in-isolate latch and test reset hook, and retention-aware pruning of rate_counters.
Request gate wiring and structured logging
apps/push-relay/src/relay.ts
Wires budget/rate-limit gates into handleRequest and adds structured auth_failed, apns_error, and claim_conflict log events across auth, APNs delivery, and claim handling.
Test harness and documentation
apps/push-relay/test/relay.test.ts, apps/push-relay/README.md, apps/push-relay/wrangler.jsonc
Extends the fake D1 database to emulate rate_counters, adds test helpers and rate-limit/budget test cases, and documents abuse controls and observability in README and wrangler.jsonc.

Tunnel-relay lifecycle logging

Layer / File(s) Summary
Structured tunnel event logging
apps/tunnel-relay/src/tunnelDo.ts, apps/tunnel-relay/wrangler.jsonc, apps/tunnel-relay/README.md
Adds a logTunnel helper and emits auth_failed, host_registered, and connect_rejected events at key control points, with matching wrangler observability config and README docs.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • arul28/ADE#705: Prior PR to the same apps/push-relay/src/relay.ts worker that this PR extends with the new budget and rate-limiting logic, migration, and test harness updates.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: relay spend guards, rate limiting, daily budget enforcement, and structured logging.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ade/relay-spend-guards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread apps/push-relay/src/relay.ts Outdated
Comment thread apps/push-relay/src/relay.ts
Document the in-worker rate limits, daily spend budget, and structured logging
in both worker READMEs and the push-notifications feature doc: the tunable
wrangler vars (DAILY_REQUEST_BUDGET / IP_RATE_LIMIT_PER_MIN /
CLAIM_RATE_LIMIT_PER_MIN), the rate_counters table, and how to view logs
(dashboard + wrangler tail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arul28

arul28 commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@copilot review but do not make fixes

- recordDailyBudget increments and reads the daily counter in one
  INSERT ... RETURNING statement, so a request never evaluates a count
  staler than its own increment; residual cross-request overshoot is
  bounded by in-flight concurrency (immaterial for a coarse 1M/day
  backstop) and now documented.
- Skip the per-IP rate gates when cf-connecting-ip is absent: Cloudflare
  always sets it on the edge, so absent = off-edge (wrangler dev / service
  binding) where a shared `unknown` bucket would only throttle callers
  against each other. The global daily budget still applies, and the header
  can't be stripped on the real edge to bypass the gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arul28

arul28 commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d340948f07

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/push-relay/src/relay.ts Outdated
Comment thread apps/push-relay/src/relay.ts Outdated
Comment thread apps/push-relay/src/relay.ts Outdated
…erve log kind

- recordDailyBudget now runs before the per-IP gate, so a rate-limited
  429 (which still ran the Worker + a D1 read) advances the daily spend
  cap. A single abusive IP can no longer generate unbounded billable
  429s the cap never sees.
- Per-IP and /claim gates always apply. When cf-connecting-ip is absent
  (off-edge only — Cloudflare always sets it on the real edge) callers
  share one fail-closed `unknown` bucket, so the unauthenticated write
  path is never bypassed. (Resolves the tension between the two prior
  absent-IP review findings in favor of preserving the limit.)
- logEvent spreads `kind` LAST so a caller field named `kind` can't
  overwrite the event name; the apns_error calls' colliding `kind` field
  is renamed `push`, so `apns_error` log filters catch real failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arul28

arul28 commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 137a164568

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/push-relay/src/relay.ts Outdated
Comment thread apps/push-relay/src/relay.ts
Comment thread apps/push-relay/src/relay.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/push-relay/src/relay.ts (1)

1117-1165: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Count oversized requests before the shared budget check
contentLengthExceeds() returns before recordDailyBudget() and the IP/claim limiters, so oversized requests can still consume Worker invocations without advancing DAILY_REQUEST_BUDGET or tripping the per-IP gates. Move the 413 response behind the shared accounting step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/push-relay/src/relay.ts` around lines 1117 - 1165, The oversized-request
early return in handleRequest is bypassing the shared accounting path, so move
the contentLengthExceeds check to after recordDailyBudget and before any
route-specific handling while keeping the existing json 413 response. Update
handleRequest in relay.ts so every request, including payload-too-large cases,
still flows through budgetTrippedNow, recordDailyBudget, and the per-IP/claim
gating before returning.
🧹 Nitpick comments (1)
apps/push-relay/src/relay.ts (1)

486-490: 🔒 Security & Privacy | 🔵 Trivial

Persisted client-IP logging — confirm retention expectations.

clientIp() feeds raw IPs into auth_failed, rate_limited, and claim_conflict structured logs, which with observability.enabled are persisted and queryable in the dashboard. This is standard abuse-log practice, but since IPs are personal data under most privacy regimes, worth confirming intended log retention/access controls align with data-handling expectations for this service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/push-relay/src/relay.ts` around lines 486 - 490, The structured logs
emitted from clientIp() in auth_failed, rate_limited, and claim_conflict are
persisting raw IPs when observability.enabled is on, so update this path to meet
the service’s intended data-handling policy. Either reduce exposure by masking
or hashing the IP before it reaches those log events, or add an explicit
retention/access-control gate around the log fields in relay.ts so the persisted
payload matches the privacy expectation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/tunnel-relay/src/tunnelDo.ts`:
- Around line 131-134: The auth failure observability is incomplete because the
earlier 401 path in handlePipe() returns without emitting the same auth_failed
signal as the verified query failure path. Update handlePipe() so its !secret
branch logs auth_failed with the same shape used in tunnelDo.ts (including role
and machineKey when available) before returning the 401 response, mirroring the
existing logTunnel call used after verifySignedQuery().

---

Outside diff comments:
In `@apps/push-relay/src/relay.ts`:
- Around line 1117-1165: The oversized-request early return in handleRequest is
bypassing the shared accounting path, so move the contentLengthExceeds check to
after recordDailyBudget and before any route-specific handling while keeping the
existing json 413 response. Update handleRequest in relay.ts so every request,
including payload-too-large cases, still flows through budgetTrippedNow,
recordDailyBudget, and the per-IP/claim gating before returning.

---

Nitpick comments:
In `@apps/push-relay/src/relay.ts`:
- Around line 486-490: The structured logs emitted from clientIp() in
auth_failed, rate_limited, and claim_conflict are persisting raw IPs when
observability.enabled is on, so update this path to meet the service’s intended
data-handling policy. Either reduce exposure by masking or hashing the IP before
it reaches those log events, or add an explicit retention/access-control gate
around the log fields in relay.ts so the persisted payload matches the privacy
expectation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e5098b2e-da80-4b7d-9023-7876989b065d

📥 Commits

Reviewing files that changed from the base of the PR and between e78308f and 137a164.

⛔ Files ignored due to path filters (7)
  • docs/features/chat/README.md is excluded by !docs/**
  • docs/features/chat/composer-and-ui.md is excluded by !docs/**
  • docs/features/chat/transcript-and-turns.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/README.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/ios-companion.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/push-notifications.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/remote-commands.md is excluded by !docs/**
📒 Files selected for processing (8)
  • apps/push-relay/README.md
  • apps/push-relay/migrations/0002_rate_and_budget.sql
  • apps/push-relay/src/relay.ts
  • apps/push-relay/test/relay.test.ts
  • apps/push-relay/wrangler.jsonc
  • apps/tunnel-relay/README.md
  • apps/tunnel-relay/src/tunnelDo.ts
  • apps/tunnel-relay/wrangler.jsonc

Comment thread apps/tunnel-relay/src/tunnelDo.ts
…ne logging

- checkRateLimit now increments and reads in one atomic INSERT ... ON
  CONFLICT ... RETURNING (window rollover handled inline via CASE), so a
  parallel burst at a window boundary can't all read below the limit and
  all be admitted — matching the budget counter's atomicity.
- Add an hourly cron (scheduled handler) that prunes suppression /
  registration / rate-limit rows independent of request traffic, so pure
  /claim / 404 / unauthorized traffic from rotating IPs can't leave stale
  rate_counters rows to accumulate.
- tunnel-relay: log auth_failed on the unknown-machine (!secret) 401
  branches in handleHost/handlePipe too, closing the observability gap.

Both workers redeployed; live smoke confirms the atomic /claim limit
trips at the configured value on real D1 and the cron schedule registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@arul28

arul28 commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f025850ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/push-relay/src/relay.ts
Comment thread apps/push-relay/wrangler.jsonc Outdated
- checkRateLimit's atomic upsert gains a WHERE guard on the DO UPDATE, so
  an already-over-limit window is a no-op (no write) and RETURNING yields
  no row → a sustained flood costs a read, not a write, per rejected hit.
  Keeps the atomicity from the prior round while restoring the read-only
  rejection path the write-cost finding flagged.
- Lower DEFAULT_DAILY_REQUEST_BUDGET 1,000,000 → 500,000/day and correct
  the sizing math to count the guards' OWN ~2 D1 writes/request: at 1M/day
  those writes crossed the 50M/mo D1 free tier and pushed a capped month
  toward ~$16; at 500k/day requests+writes stay ≈ $1.50, safely under the
  ~$10 ceiling. Comment + README + docs updated to match.

Redeployed; live smoke confirms the WHERE-guarded /claim limit trips
correctly on real D1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arul28

arul28 commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 077b664d0a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@arul28
arul28 merged commit 770a31e into main Jul 7, 2026
33 checks passed
@arul28
arul28 deleted the ade/relay-spend-guards branch July 7, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant