Skip to content

AbuseRateLimitError.Error() silently drops RetryAfter field #4180

@danmoseley

Description

@danmoseley

Summary

AbuseRateLimitError.Error() does not include the RetryAfter duration, even when it is populated from the Retry-After HTTP header. Callers that surface .Error() to users, logs, or downstream systems silently lose actionable retry information.

Motivation

This gap has existed since AbuseRateLimitError was introduced in 2016 (PR #441), but was relatively minor because Go callers can use errors.As to inspect .RetryAfter directly from the struct — the string form is mostly used for logging.

It has become more impactful with the rise of AI coding agents. The github-mcp-server exposes GitHub API operations as MCP tools, and AI agents receive tool results as plain strings — there is no way for the model to inspect the underlying struct. When an agent hits a secondary rate limit, it currently sees no retry duration and must choose between retrying immediately (worsening the situation) or waiting an arbitrary amount of time. Including the duration in .Error() gives agent frameworks and models the information needed to back off correctly.

Current behavior

func (r *AbuseRateLimitError) Error() string {
    return fmt.Sprintf("%v %v: %v %v",
        r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
        r.Response.StatusCode, r.Message)
}

When GitHub returns a Retry-After: 60 header, RetryAfter is populated, but .Error() produces only:

GET https://api.github.com/search/code: 403 You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later.

The retry duration is silently dropped — the only way to recover it is to errors.As to *AbuseRateLimitError and read .RetryAfter directly.

Contrast with RateLimitError

RateLimitError.Error() does include timing via formatRateReset:

func (r *RateLimitError) Error() string {
    return fmt.Sprintf("%v %v: %v %v %v",
        r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
        r.Response.StatusCode, r.Message, formatRateReset(time.Until(r.Rate.Reset.Time)))
}

Which produces, e.g.:

GET https://api.github.com/search/code: 403 API rate limit exceeded for user ID 12345. [rate reset in 47s]

Suggested fix to AbuseRateLimitError

func (r *AbuseRateLimitError) Error() string {
    retryInfo := ""
    if r.RetryAfter != nil {
        retryInfo = fmt.Sprintf(" [retry after %v]", r.RetryAfter.Round(time.Second))
    }
    return fmt.Sprintf("%v %v: %v %v%v",
        r.Response.Request.Method, sanitizeURL(r.Response.Request.URL),
        r.Response.StatusCode, r.Message, retryInfo)
}

Which would produce, e.g.:

GET https://api.github.com/search/code: 403 You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later. [retry after 60s]

This is a non-breaking additive change to the string output, and makes AbuseRateLimitError consistent with RateLimitError.

Note

This issue was drafted with the assistance of GitHub Copilot.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions