fix(retry): restrict retries to safe requests and bound Retry-After (#33) - #37
Merged
Merged
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
doRequestWithHeaders retried on transport error, 429 and 5xx without looking at the request method, and slept for whatever Retry-After the server named. Measured against main 3b24c6d and against production on 2026-09-13: $ curl -sD- https://api.oilpriceapi.com/v1/demo/prices/latest HTTP/2 429 retry-after: 28197 x-ratelimit-remaining-day: 0 28,197 seconds is 7 hours 50 minutes, counting down to the next daily reset. A client using context.Background() has no deadline, so GetLatestPrices against that endpoint blocked for the whole of it with no way to cancel. Driven through the real client against an httptest server answering the same header, the call had not returned after 10 seconds; it now returns in 796µs with *RateLimitError{RetryAfter: 28197}. Four further defects found while verifying, all confirmed with a counting test server: - POST /v1/subscriptions was sent 4 times on a 503 and POST /v1/webhooks 3 times after a connection died mid-response. Both are ambiguous: the server may have committed before the response was lost, so the replay is a duplicate write. The API offers no idempotency key that would make that safe. - Retry-After: -3600 passed strconv.Atoi, giving a negative duration. time.After fires on that immediately, so backoff collapsed to a hot loop: 4 requests in 1.01ms. - A 429 carrying X-RateLimit-Remaining-Day: 0 was retried 4 times. A spent daily or monthly allowance does not refill inside a retry budget. - Retry-After as an HTTP-date (RFC 9110 permits it) was not parsed at all, and a huge delta-seconds value could overflow int64 nanoseconds when multiplied by time.Second. - WithRetries(-1) sent zero requests and returned "request failed after -1 retries: %!w(<nil>)" — a formatting artefact of wrapping a nil error, not a diagnosis. retry.go now holds one classifier used by every request: - only safe methods (GET/HEAD/OPTIONS/TRACE) are replayed; - only recoverable 429s are retried, durable quota exhaustion is returned immediately as the typed *RateLimitError; - Retry-After is parsed in both RFC forms, rejected when absent, unparseable, zero, negative or in the past (falling back to exponential backoff), and clamped so it cannot overflow; - a wait longer than WithMaxRetryWait (default 60s) or longer than the caller's own context deadline ends the retry loop and returns the typed error carrying the server's requested wait, rather than sleeping or re-sending early; - context cancellation still interrupts every wait. handleError now reports the same parsed value, so a date-form Retry-After reaches RateLimitError.RetryAfter instead of 0. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
karlwaldman
force-pushed
the
fix/33-bound-retries-and-retry-after
branch
from
September 13, 2026 16:35
580edf1 to
9ac92ee
Compare
This was referenced Sep 13, 2026
Closed
[P2] #36 origin-pinning fix is merged but untagged — go get still installs the vulnerable v1.5.2
#39
Closed
Merged
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
Minor, not patch. #36 and #37 both changed behaviour callers can observe. Until this tag exists, `go get` installs v1.5.2 -- the version that sends `Authorization: Token <key>` to an attacker-controlled host. Merging #36 protected nobody. Breaking: * POST/PUT/PATCH/DELETE are no longer retried. CreateWebhook was observed sending four POSTs on a single 503. * A path without a leading `/` is rejected rather than concatenated. Raw("v1/prices") previously produced the host api.oilpriceapi.comv1. Version constant and CHANGELOG heading moved together, as release_contract_test.go requires. go test ./... ok (16.496s), gofmt and go vet clean. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
…48) WithMaxRetryWait bounded each individual wait and nothing bounded their sum. With the default three retries, a server answering Retry-After just under the budget held the call for roughly three times the number the caller configured, and under context.Background() there is no deadline to rescue it. Measured on main: 5.007s of automatic wait against a 1.2s budget, and 4.005s against a 1.5s budget on the transport-failure path. That is the problem the option was added for in #37, so bounding the total is the fix rather than a new option. doRequestWithHeaders now tracks the wait already spent in this call and makes each decision against what is left: - 429/5xx: a Retry-After longer than the remaining budget stops the retry loop and returns the typed error, exactly as a wait past the whole budget already did. Remaining budget is a stop condition, not a clamp, because waiting less than the server asked would not clear the limit. - Transport failures: the exponential backoff is clamped to the remaining budget, so retries still happen while there is budget for them and stop when there is not. Behaviour change, documented on WithMaxRetryWait and DefaultMaxRetryWait: the number is now the total automatic wait for one call. It can only make a call return sooner than before, never later. A retry that fits in the budget still happens, and the #37 behaviour — a single Retry-After past the budget returns *RateLimitError immediately, carrying the server's requested wait — is unchanged and covered by a test. Closes #42 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <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.
Closes #33.
Confirmed live, twice over
Production, 2026-09-13. The keyless demo endpoint answers 429 with a Retry-After counting down to the next daily reset:
28,197s = 7h 50m. The issue recorded 31,612s (8h 47m) earlier the same day; the number is whatever is left until midnight UTC, so the worst case is ~24h.
Through the real client,
client.go:745(verified — that is theRetry-Afterread,:747the uncappeddelay =) passed that straight totime.After. A caller oncontext.Background()has no deadline to rescue it.Measured before / after — the 7.8-hour block
Driven through
GetLatestPricesagainst anhttptestserver replaying the production header, withcontext.Background():*RateLimitError{RetryAfter: 28197}context.Background())The test asserts on observable behaviour — a real 429 with a real huge header driven through the public method, returning promptly with the typed error — not on an internal delay variable.
The other four, also confirmed on
main3b24c6dEach was reproduced with a counting test server before anything was written.
mainCreateSubscriptionsentPOST /v1/subscriptions4 times on a 503CreateWebhooksentPOST /v1/webhooks3 times after the connection died mid-responseRetry-After: -3600passedstrconv.Atoi;time.Afteron a negative duration fires at once — 4 requests in 1.01msX-RateLimit-Remaining-Day: 0retried 4 times*RateLimitErrorWithRetries(-1)sent 0 requests and returnedrequest failed after -1 retries: %!w(<nil>)— a nil-wrap formatting artefact, not a diagnosis*ConfigurationError, 0 requestsRateLimitError.RetryAfterread 0Retry-After: 9223372036854775807×time.Secondwraps int64 nanoseconds; 4 requests sentOne claim from the issue that did not hold
max_retries=0being ignored was a Python defect (max_retries=0still retried 3 times). It does not reproduce in Go —WithRetries(0)already sent exactly one request onmain. The Go analogue isWithRetries(-1), which disabled the client entirely and reported it as a%!w(<nil>)format error. Both are now covered by a regression test.The fix
retry.goholds one classifier used by every request. Three rules, in order:GET/HEAD/OPTIONS/TRACE. A retriedPOSTorPATCHis a duplicate write whenever the server committed and the response was lost, and the API offers no idempotency key that would make it safe.X-RateLimit-Remaining-Dayor-Monthat zero means the allowance is spent and will not refill inside any retry budget, so the typed*RateLimitErrorcomes back immediately and the caller can surface an upgrade path instead of stalling. The per-minuteX-RateLimit-Remainingburst window is still retried.Retry-Afteris parsed in both RFC 9110 forms, rejected when absent, unparseable, zero, negative or in the past (falling back to exponential backoff rather than to a zero or negative delay), and clamped at one year so the× time.Secondconversion cannot overflow. A delay beyondWithMaxRetryWait(new option, default 60s) or beyond the caller's owncontextdeadline ends the retry loop and returns the typed error carrying the server's requested wait — it does not sleep, and it does not re-send early. Context cancellation still interrupts every wait.handleErrornow uses the same parser, so a date-formRetry-AfterreachesRateLimitError.RetryAfterinstead of 0.PUTandDELETEare deliberately excluded despite being idempotent by definition. Idempotent means a repeat has the same effect on the resource, not that the caller observes the same outcome; a replayedDELETEcan remove a resource recreated in between. This makesDeleteWebhook,DeleteAlertandDeleteSubscriptionnon-retrying. Say the word if you would rather they retried.Red / green
Red, re-run against the new
origin/main6ceca9b6—client.gotaken from that commit (origin guard included) plus the new option/field declarations only, so the file compiles. #36's path changes did not alter the retry defect; the output is the same as before the rebase:The four already-passing tests are deliberate regression guards: a
Retry-Afterinside the budget is still honoured, a burst 429 is still retried, a safeGETstill retries through a transport error, and context cancellation still interrupts a wait. They must stay green on both sides, and they do.Green, with the fix:
Red-capability re-verified after the rebase by restoring the pre-fix
doRequestWithHeadersfrom6ceca9b6and re-running: the same nine tests fail again, then pass on restore.The #36 origin tests inherited from main also stay green on this branch:
Full suite
Baseline re-measured on the new main myself, not carried over:
origin/main6ceca9b6go test ./...ok ... 1.314sok ... 16.489sgo test -race ./...ok ... 18.232s(265 is the post-#36 baseline; it was 237 before #36 landed.)
go vet ./...clean,gofmt -l .clean. Go 1.26.5 darwin/arm64. The suite is slower because several new tests exercise real one-second backoff waits rather than asserting on an internal timer.Reviewer notes
POST,PATCHandDELETEno longer retry. That is the point of the issue, but it means a transient 503 onCreateSubscriptionnow surfaces to the caller instead of being papered over. Minor-version material, not a patch.WithMaxRetryWaitdefault is 60s. Chosen so a normal burst limit (per-minute window) is still absorbed while a daily reset never is. A caller who genuinely wants to wait longer can raise it, and cancellation still works.X-RateLimit-Remaining-Day/-Month, which production sends on every 429 (see the curl above). It does not parse the error body, so a wording change cannot break it.release_contract_test.gorequires the top changelog heading to equalVersion, so the entry has to land with the version bump at release time.Rebase onto
6ceca9b6Two conflicts, both the "both sides added" shape, both resolved by keeping each side whole:
errors.go—InvalidPathError(fix(security): reject raw paths that change the authenticated API origin (#32) #36) andConfigurationError(fix(retry): restrict retries to safe requests and bound Retry-After (#33) #37) were appended at the same point. Both kept.client.go— the top ofdoRequestWithHeaders. fix(security): reject raw paths that change the authenticated API origin (#32) #36 inserted theresolveEndpointcall there; fix(retry): restrict retries to safe requests and bound Retry-After (#33) #37 inserted the retry-config validation and method classification. Resolved as: config validation, then path resolution, then method classification, then the loop. Config is checked first because a negativeWithRetriesis a programming error the caller should hear about regardless of the path.The request-construction line resolved to #36's
http.NewRequestWithContext(ctx, method, requestURL, body), so every attempt — including every retry — is built from the origin-validated URL rather than fromc.baseURL + endpoint. That is the property that had to survive, and it is asserted by the #36 tests, which are on this branch and green.Nothing was dropped from either change. The two flagged judgement calls are unchanged by the rebase and still open:
DELETEexcluded from retries, and..segments rejected.🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo