Honor Retry-After as a floor under the backoff (#189) - #215
Merged
Merged
Conversation
The retry loop was pure full-jitter exponential and never read the header, so a 429 asking for 30 seconds was retried inside max_backoff's 20-second window: an attempt spent arriving early to be refused again. A service that documents its throttling, which is the case that started this, was the one the client handled worst. Both RFC 9110 forms are read, delta-seconds and HTTP-date, the latter through the runtime's own IMF-fixdate parser rather than a second one. A date already past asks for nothing rather than for negative time, and a value that does not parse is ignored in favor of ordinary backoff: a peer's malformed hint is not worth failing a call over. Floor, never ceiling. Retry-After is a minimum, so it can raise the wait and never shorten the backoff that exists to stop a client coming back early. retry_after_cap (default 60s) bounds how far a number the peer sent is trusted. Separate from max_backoff because they bound different things: one a guess this client made, the other a number it was sent. Higher by default because truncating a documented 30 to 20 is worse than useless. Written test-first: the six positive cases were red against a stubbed RetryAfterDelay, and the absent/malformed ones passed against it from the start, which is the shape that says they pin the negative space. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
RFC 9110 §5.6.7 requires a recipient to accept IMF-fixdate and the two obsolete forms, and Retry-After's HTTP-date alternative inherits that. Only the first was read, so a server still emitting rfc850 or asctime was silently ignored and the client came back early — the bug this change exists to fix, for exactly the old servers most likely to hit it. ParseHttpDate lives in the http layer because it is an HTTP field concern, not a Smithy one: Timestamp::Parse(kHttpDate) stays strict IMF-fixdate, which is what @timestampFormat http-date means and what the protocol conformance suites pin. The obsolete forms are normalized into IMF-fixdate text and handed to that same parser, so there is no second civil-date arithmetic to disagree with the first. The two-digit year follows §5.6.7's fifty-year rule, without which a 1994 timestamp reads as 2094 and a delay in the past becomes seventy years in the future. The delta was a signed subtraction of two Timestamps. The function is public and Timestamp's unchecked factory can build instants whose difference exceeds int64, where that is undefined rather than large. Ordering is checked first; the unsigned difference of an ordered pair is exact, then saturated. Written test-first. The weekday check in the IMF-fixdate parser earned its keep immediately by rejecting test data that carried 1994's Sunday over to a 2030 date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
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.
Item 4 of #189. Small and local to
retry.cc, plus an HTTP-date parser in the http layer.What
SendWithRetrieswas pure full-jitter exponential and never looked at the response. A 429 sayingRetry-After: 30got retried insidemax_backoff's 20-second window, which spends an attempt arriving early to be refused again. The service that documents its throttling is the one the client handled worst, and that is the case MoonBase#1527 walks into: Lichess publishes its rate limits and its 429 backoff rules.Now, when a retried response carries the header, that delay is a floor under the backoff for that attempt.
30) and HTTP-date, and for the second, all three spellings §5.6.7 requires a recipient to accept: IMF-fixdate, the obsolete RFC 850, and asctime. Reading only the first would mean silently ignoring exactly the old servers most likely to send the others, and coming back early against them.+30,30,3.5,30s) are all rejected.Retry-After: 0cannot shorten the backoff that exists to stop a client coming back early.opal::http::ParseHttpDateNew, and in the http layer because reading an HTTP field timestamp is an HTTP concern rather than a Smithy one.
Timestamp::Parse(kHttpDate)stays strict IMF-fixdate: that is what@timestampFormathttp-date means and what the conformance suites pin, so loosening it for a modeled member would be the wrong trade. The obsolete forms are normalized into IMF-fixdate text and handed to that same parser, so no second civil-date arithmetic exists to drift from the first.The two-digit year follows §5.6.7's fifty-year rule. Without it
06-Nov-94reads as 2094, turning a timestamp in the past into a seventy-year delay clamped to the cap.retry_after_capNew on
RetryPolicy, default 60 s, bounding how far a number the peer sent is trusted. Deliberately separate frommax_backoff, which bounds a guess this client made, and higher by default: a service documentingRetry-After: 30wants 30, and truncating that tomax_backoffreproduces the bug this fixes. Lower it when a slow retry is worse for you than a failed one.Total wall-clock across attempts still has no ceiling of its own. That is item 7, untouched here, and the docs say so rather than implying this closed it.
opal::RetryAfterDelay(headers, now)is public, for callers driving their own loop. Being public is also why the date subtraction is ordered before it is taken and computed inuint64_t:Timestamp's unchecked factory can build instants whose difference exceedsint64, where a signed subtraction is undefined rather than merely large.Testing
Written test-first. The six positive cases were red against a stubbed
RetryAfterDelayreturningnullopt; the absent-and-malformed cases passed against that stub from the start, which is the shape that says they pin the negative space rather than the feature.ParseHttpDate: all three formats resolving to one instant, the fifty-year rule in both directions and against two different references, and a bank of near-misses (wrong zone, impossible day, unknown month, truncated).RetryAfterDelay: delta-seconds, HTTP-date future and past, the obsolete spellings, absent, nine malformed values, saturation, and the overflow pair (a year-9999 header againstINT64_MIN, the epoch againstINT64_MAX).nowthemselves.bazel test //... --config=werror: 130 pass. Consumer module: 17 pass.--config=noexceptbuild,gradle build spotlessCheck, clang-format, clang-tidy (including the Beast transport) and buildifier all clean. The ubsan config could not run in this environment, its sanitizer runtime is not installed, so CI's sanitizer jobs are the check on the undefined behavior itself.Worth recording: the IMF-fixdate parser validates the weekday against the date, and it earned that immediately by rejecting test data of mine that carried 1994's Sunday over to a 2030 date.
Checklist
bazel test //...and(cd codegen && gradle build spotlessCheck)pass locally🤖 Generated with Claude Code
https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ