Skip to content

feat: typed rate-limit errors carrying the server's Retry-After hint - #118

Open
vegardx wants to merge 1 commit into
actions:mainfrom
vegardx:feat/rate-limit-error
Open

feat: typed rate-limit errors carrying the server's Retry-After hint#118
vegardx wants to merge 1 commit into
actions:mainfrom
vegardx:feat/rate-limit-error

Conversation

@vegardx

@vegardx vegardx commented Jul 19, 2026

Copy link
Copy Markdown

What

Rate-limited responses currently surface as unwrapped generic errors, so callers cannot distinguish rate limiting from other failures without string-matching status text — and the server's requested delay is lost entirely (the Retry-After header is not part of the error message). This wraps them like the existing status sentinels (BadRequestError, UnauthorizedError, NotFoundError, ConflictError):

if errors.Is(err, scaleset.RateLimitedError) {
    var rl *scaleset.RateLimitError
    errors.As(err, &rl)
    // rl.RetryAfter is the server's requested delay (zero when none was sent)
}

Callers building autoscalers or control planes on this SDK need this signal to back off adaptively, emit throttling metrics, and honor the delay GitHub's API guidelines ask integrators to respect. It follows the sentinel pattern the client already uses for 400/401/404/409 — one more case in the existing switch, plus the delay hint.

Classification

Covers how GitHub actually signals rate limiting:

  • 429 — always.
  • 403 with Retry-After (secondary rate limits) or with X-RateLimit-Remaining: 0 (a spent primary limit). Unrelated 403s are untouched.

The delay hint prefers Retry-After (both RFC 9110 forms — delay-seconds and HTTP-date — with overflow and negative guards) and falls back to X-RateLimit-Reset when the primary limit is spent.

Bug fix this depends on

go-retryablehttp retries 429s internally, and its default ErrorHandler discards the final response once retries are exhausted — so the status classification in newRequestResponseError was unreachable on exactly the rate-limited path (the caller got a bare giving up after N attempt(s) error with no response attached). This PR installs retryablehttp.PassthroughErrorHandler centrally in newRetryableHTTPClient (only when no custom handler is set), replacing the single hand-rolled copy in getActionsServiceAdminConnectionRequest. After exhausted retries the final response now flows through the normal status-code error path, so these errors also gain the request/activity-ID context that path adds.

Testing

  • Table-driven classification tests (429/403 variants, unrelated 403 untouched).
  • retryAfterHint tests against a fixed clock: both RFC 9110 forms, the X-RateLimit-Reset fallback, precedence, past dates, negative/invalid/overflowing values.
  • A live-server test through the full retry stack asserting the typed error survives exhausted retries.

We carry this in our fork, where the consumer uses it to distinguish rate-limit push-back from ordinary failures in an adaptive mint-concurrency limiter.

Note on the base

This branch is stacked on #116 (fix/testdata-cert-expiry) so its test suite runs green — main currently fails TestServerWithSelfSignedCertificates on the expired committed testdata certificates that #116 replaces with runtime-generated ones. The rate-limit change itself is the single top commit; the diff collapses to it once #116 merges.

Copilot AI review requested due to automatic review settings July 19, 2026 14:03
@vegardx
vegardx requested review from a team and nikola-jokic as code owners July 19, 2026 14:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vegardx
vegardx force-pushed the feat/rate-limit-error branch from d7ea105 to ed6baa8 Compare July 21, 2026 10:00
Rate-limited responses previously surfaced as unwrapped generic errors,
so callers could not distinguish rate limiting from other failures
without string-matching status text. Wrap them like the other status
sentinels: errors.Is(err, RateLimitedError) matches the condition, and
errors.As against *RateLimitError reads the server's requested delay.

Classification covers how GitHub actually signals rate limiting: 429
always; 403 when it carries Retry-After (secondary limits) or
X-RateLimit-Remaining: 0 (a spent primary limit), with unrelated 403s
untouched. The delay hint prefers Retry-After (delay-seconds or
HTTP-date per RFC 9110, with overflow and negative guards) and falls
back to X-RateLimit-Reset when the primary limit is spent.

The retryable client's default error handler discards the final
response once retries are exhausted, which made status classification
unreachable on exactly the rate-limited path (429 is retried
internally). The client now installs PassthroughErrorHandler centrally
in newRetryableHTTPClient, replacing the single hand-rolled copy in
getActionsServiceAdminConnectionRequest, so the last response flows
into the normal status-code error path.
@vegardx
vegardx force-pushed the feat/rate-limit-error branch from ed6baa8 to c67c39d Compare July 22, 2026 18:22
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