feat(http): surface Retry-After, and retry a rate limit when asked - #20
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aRetry-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_afterWhole 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 falseOff 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_retriesset 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 (RetryOn429alone is a no-op there:maxAttempts = maxRetries + 1with the zero value gives one attempt).The two decisions in
Rail0::Backoffworth reviewingPure and tested, because both are easy to get backwards and invisible once wrong.
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 aRetry-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.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,
POSTincluded, 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/garbageRetry-Aftertreated as no instruction, and that the drawn jitter is actually random and never dips below the instruction.spec/errors_spec.rb(+7) —retry_afterpresent 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 itsretry_after; and a POST retried like a GET.bundle exec rspec: 153 examples, 0 failures.Next in this series
Gateway: send
RateLimit-Limit/Remaining/Reseton every response and makeRetry-Afterthe 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