Skip to content

Honor Retry-After as a floor under the backoff (#189) - #215

Merged
aaylward merged 2 commits into
mainfrom
claude/kind-fermi-elfabg-189-retry-after
Sep 12, 2026
Merged

aaylward merged 2 commits into
mainfrom
claude/kind-fermi-elfabg-189-retry-after

Conversation

@aaylward

@aaylward aaylward commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Item 4 of #189. Small and local to retry.cc, plus an HTTP-date parser in the http layer.

What

SendWithRetries was pure full-jitter exponential and never looked at the response. A 429 saying Retry-After: 30 got retried inside max_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.

  • Both RFC 9110 §10.2.3 forms, delta-seconds (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.
  • A date already past asks for nothing, not for negative time.
  • A value that does not parse is ignored and ordinary backoff applies. A peer's malformed hint is not worth failing a call over. The shape is checked before the value is read, so the near-misses a lax parser would take (+30, 30, 3.5, 30s) are all rejected.
  • Floor, never ceiling. Retry-After: 0 cannot shorten the backoff that exists to stop a client coming back early.
  • An absurd value saturates rather than wrapping on its way to the cap that makes it harmless.

opal::http::ParseHttpDate

New, 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 @timestampFormat http-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-94 reads as 2094, turning a timestamp in the past into a seventy-year delay clamped to the cap.

retry_after_cap

New on RetryPolicy, default 60 s, bounding how far a number the peer sent is trusted. Deliberately separate from max_backoff, which bounds a guess this client made, and higher by default: a service documenting Retry-After: 30 wants 30, and truncating that to max_backoff reproduces 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 in uint64_t: Timestamp's unchecked factory can build instants whose difference exceeds int64, where a signed subtraction is undefined rather than merely large.

Testing

Written test-first. The six positive cases were red against a stubbed RetryAfterDelay returning nullopt; 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).
  • Pure RetryAfterDelay: delta-seconds, HTTP-date future and past, the obsolete spellings, absent, nine malformed values, saturation, and the overflow pair (a year-9999 header against INT64_MIN, the epoch against INT64_MAX).
  • Through the loop: the floor raises a 100 ms backoff to 2 s and does not lower it below 100 ms; the cap clamps an hour to 5 s; malformed and absent leave the backoff alone; a transport error has no headers to honor; and an HTTP-date is measured against the real clock, which the pure tests cannot pin because they supply now themselves.
  • bazel test //... --config=werror: 130 pass. Consumer module: 17 pass. --config=noexcept build, 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

  • Tests added/updated for the change
  • bazel test //... and (cd codegen && gradle build spotlessCheck) pass locally
  • Formatting clean (clang-format, buildifier, spotless)
  • Architectural decisions recorded as an ADR (not applicable: a knob, a header read, and a parser, recorded in the research doc's status table)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ

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
Comment thread runtime/src/client/retry.cc Outdated
Comment thread runtime/src/client/retry.cc Outdated
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
@aaylward
aaylward enabled auto-merge September 12, 2026 03:15
@aaylward
aaylward merged commit 901fd94 into main Sep 12, 2026
16 checks passed
@aaylward
aaylward deleted the claude/kind-fermi-elfabg-189-retry-after branch September 12, 2026 03:28
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.

2 participants