Skip to content

feat(http): surface Retry-After, and retry a rate limit when asked - #20

Merged
pviti merged 2 commits into
mainfrom
feature/rate-limit-policy
Aug 18, 2026
Merged

feat(http): surface Retry-After, and retry a rate limit when asked#20
pviti merged 2 commits into
mainfrom
feature/rate-limit-policy

Conversation

@pviti

@pviti pviti commented Aug 18, 2026

Copy link
Copy Markdown
Member

The gap

The gateway throttles two surfaces independently — public per-IP (100/60s) and authenticated per session (300/60s) — and answers 429 with code: "rate_limited" and a Retry-After. This SDK dropped that header entirely: "rate limited" arrived with no idea for how long, so a caller could only guess. rail0-go and rail0-ts both surfaced it; Ruby was the outlier.

That is the bug. The retry below is the policy built on top of it.

ApiError#retry_after

Whole seconds, nil on every other error and nil when the header is absent, zero or unparseable. Zero is the case worth naming: it is a valid duration, so honouring it produces a burst of back-to-back requests against the limiter that just asked for a pause.

retry_on_429, default false

Off by default on purpose: an automatic sleep hides back-pressure from the process that could react to it, and in a request/response app it turns a rate limit into a stalled page. A job turns it on — and it sleeps the calling thread, which the README says.

Self-sufficient by design. It does not need max_retries set as well. That pairing is a footgun — the flag would silently do nothing, which is exactly the shape of a bug this same review found in rail0-go (RetryOn429 alone is a no-op there: maxAttempts = maxRetries + 1 with the zero value gives one attempt).

The two decisions in Rail0::Backoff worth reviewing

Pure and tested, because both are easy to get backwards and invisible once wrong.

  1. Jitter is ADDITIVE on a server-instructed wait, multiplicative only on a guess. Textbook full jitter multiplies the delay by rand() — right for a backoff we invented, wrong for a Retry-After: scaling the server's own number down means retrying before the window it named has passed, which is a second 429 by construction. Jitter is still needed, because callers align: rail0-admin proxies every merchant over one session, so they share the bucket, are told the same number, and would wake together and recreate the burst.

  2. The cap is not paranoia. The gateway sends its whole period as Retry-After (rack_attack.rb: headers["retry-after"] = match_data[:period].to_s), not the time remaining, so a limit hit one second into the window is asked to wait the full 60. retry_after_cap (default 60) bounds both that over-wait and a hostile value from anything in between.

Security

A 429 is retried on any method, POST included, and this is the reasoning to check: Rack::Attack rejects it in middleware, before the request reaches the application, so nothing was executed and nothing can run twice. That is not true of a 502 or a timeout on a capture, where the broadcast may already be in flight — and those remain un-retried. No other HTTP status is retried.

Nothing else changes: same credential handling, no new headers sent, nothing logged that was not logged before (a throttled attempt logs with will_retry: true, exactly like a retried network error).

Performance

This is the SDK's behaviour under pressure, which is when it matters. With the flag off, one request as before. With it on, a bounded wait that honours the server's pacing instead of hammering it — strictly less load on the gateway than the retry loop a caller would otherwise write by hand around the error.

Tests

spec/backoff_spec.rb (7) — additive vs multiplicative jitter, the cap on both paths, zero/negative/garbage Retry-After treated as no instruction, and that the drawn jitter is actually random and never dips below the instruction.

spec/errors_spec.rb (+7) — retry_after present on a 429 and nil everywhere else; no retry by default; one retry from the flag alone; the budget exhausted still raising the last 429 with its retry_after; and a POST retried like a GET.

bundle exec rspec: 153 examples, 0 failures.

Next in this series

Gateway: send RateLimit-Limit/Remaining/Reset on every response and make Retry-After the time remaining rather than the whole period — that moves clients from reacting well to not arriving at the wall. Then the same policy in rail0-ts (which surfaces the header but never retries) and the clamp + jitter + self-sufficient flag in rail0-go.

🤖 Generated with Claude Code

The gateway throttles two surfaces (public per-IP, authenticated per-session) and
answers 429 with a Retry-After. This SDK dropped that header entirely: "rate limited"
arrived with no idea for how long, so a caller could only guess — and rail0-go and
rail0-ts both surfaced it. That is the gap; the retry is the policy built on top.

ApiError#retry_after carries the header as whole seconds, nil on every other error and
nil when it is absent, zero or unparseable. Zero is the case worth naming: it is a valid
duration, so treating it as one produces a burst of back-to-back requests against the
limiter that just asked for a pause.

retry_on_429 (default FALSE) makes the SDK wait it out instead. Off by default
deliberately — an automatic sleep hides back-pressure from the process that could react
to it, and in a request/response app it turns a rate limit into a stalled page — and
self-sufficient by design: it does not need max_retries set as well, because that
pairing would have made the flag a silent no-op.

The waiting lives in Rail0::Backoff, pure and tested, because two of its decisions are
easy to get backwards and invisible once wrong:

- jitter is ADDITIVE on a server-instructed wait and multiplicative only on a guess.
  Textbook full jitter scales the delay by rand(), which is right for a backoff we
  invented and wrong for a Retry-After: scaling the server's own number down means
  retrying before the window it named has passed. Jitter is still needed, because
  callers align — rail0-admin proxies every merchant over ONE session, so they share the
  bucket, are told the same number, and would wake together.
- the cap is not paranoia. The gateway sends its WHOLE period as Retry-After rather than
  the time left in the window, so a limit hit one second in is asked to wait the full 60.

A 429 is retried on any method, POST included, and the comment says why: Rack::Attack
rejects in middleware, before the request reaches the application, so nothing ran and
nothing can run twice. That is not true of a 502 or a timeout on a capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pviti pviti added enhancement New feature or request p3 Security priority: Low labels Aug 18, 2026
Full jitter multiplies the whole delay by rand(), so it can land arbitrarily close to
zero — which makes a real pause indistinguishable from the bug where a Retry-After of "0"
is honoured as a duration and the retry fires immediately. rail0-go has a test that
measures exactly that, and full jitter broke it.

Half fixed, half random spreads the herd just as well and leaves "did we actually wait"
observable. Applied in all three SDKs so they do not diverge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pviti
pviti merged commit edb2d33 into main Aug 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request p3 Security priority: Low

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant