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-After and no Ratelimit-Reset waits for Retry-After (seconds or an HTTP date) plus the usual jitter. Ratelimit-Reset still takes precedence, and a missing or unusable Retry-After keeps 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-After for these limits (rate_limiter.rb#L18-L23, helpers.rb#L702-L706). Rack::Attack throttles also send RateLimit-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's DefaultBackoff, which this client replaces, already does (client.go#L551-L558). When the context ends during a wait, go-retryablehttp returns only ctx.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-tree finds no conflict.
Edited by José M. Requena Plens

Merge request reports

Loading
Loading