feat(client): add option to honor Retry-After when backing off from a 429
What does this MR do?
Since GitLab 18.5, the application rate limits (export and import, note and tag creation, search, listing projects, among others) answer a 429 with Retry-After and no Ratelimit-Reset. rateLimitBackoff reads only Ratelimit-Reset, so these 429s get the exponential backoff from !1993 (merged), and the caller gets the 429 back within a few seconds, still inside the same window.
This MR adds a client option, WithRetryAfterBackoff(), off by default:
- With it, a 429 with
Retry-Afterand noRatelimit-Resetwaits forRetry-After(seconds or an HTTP date) plus the usual jitter.Ratelimit-Resetstill takes precedence, and a missing or unusableRetry-Afterkeeps the exponential backoff. - If that wait would end after the request context's deadline, the client keeps the exponential backoff, so the caller still gets the 429 rather than a context error.
- Its doc comment says this is planned to become the default in 5.0, with a call-out in 4.0.
It also caps a wait too close to the longest time.Duration for the jitter to fit on top, which on main wraps around to an immediate retry (a Ratelimit-Reset in the year 9999, for example). The cap applies with or without the option.
It comes from maintaining gitlab-mcp-server, which keeps a record of its upstream work in upstream-bugs.md.
Is this a breaking change?
No. It adds one option and changes no existing name, type or signature. Without the option, a 429 is handled as on main, apart from the cap above. With it, a caller can wait for the whole period of a limit: a minute for most, up to a day for some.
How was this tested?
TestRateLimitBackoff, TestRateLimitBackoff_WithoutRetryAfterBackoff, TestRetryAfterWait, TestWithRetryAfterBackoff, and three tests against an httptest server: TestRateLimitBackoff_RetryAfterDelaysTheRetry, TestRateLimitBackoff_RetryAfterNotWaitedForByDefault and TestRateLimitBackoff_RetryAfterPastTheDeadlineReturnsThe429. go test work -race passes on Go 1.26.10 and 1.27.3, and golangci-lint, gofumpt and make generate are clean.
Sources and test runs
- GitLab sets only
Retry-Afterfor these limits (rate_limiter.rb#L18-L23, helpers.rb#L702-L706). Rack::Attack throttles also sendRateLimit-Reset(request_throttle_data.rb#L143-L171) and are not affected. - GitLab asks clients to wait for
Retry-After(rate_limits.md#L108), as go-retryablehttp'sDefaultBackoff, which this client replaces, already does (client.go#L551-L558). When the context ends during a wait, go-retryablehttp returns onlyctx.Err()(client.go#L766-L790), hence the deadline check. - Against the previous head (e4a25dca) with only the option added, 10 cases fail; with the change all pass, and the new and changed tests passed 30 runs in a row under
-race. I did not trigger a real 429. - !3065 and !3143 (merged) (mine) change some of the same files in other places;
git merge-treefinds no conflict.