scalesets: distinguish a GitHub 403 from a 401 - #862
Draft
yhaliaw wants to merge 1 commit into
Draft
Conversation
ScaleSetClient.Do mapped both 401 and 403 onto the bare ErrUnauthorized
sentinel. GitHub answers 403 for a secondary rate limit and for SSO
enforcement on an organization, neither of which says anything about the
credentials, so callers reading that error conclude the credentials are
dead when they are usually fine and the refusal is temporary.
GARM does this to itself in the scaleset worker: on a failed RemoveRunner
it treats ErrUnauthorized as "our credentials may have expired or are
incorrect" and parks the runner, with a TODO to deactivate the scale set
and stop its listener. A rate limit that would have cleared on the next
pass is enough to trigger that.
Return a ForbiddenError for 403 instead. It reports as an
UnauthorizedError, so every caller that only asks whether the request was
refused keeps working, while callers that need to act on the difference
can single it out with errors.Is(err, &ForbiddenError{}) checked before
the broader case. This follows RunnerTransitionError, which reports as a
BadRequestError the same way. handleError maps it to HTTP 403, so API
clients can tell the two apart as well.
Both branches also keep the response body and URL, as every other status
branch already does. They previously returned a bare sentinel, so an
operator reading the log had nothing to tell a revoked credential from a
rate limit.
Signed-off-by: Andrew Liaw <andrew.liaw@canonical.com>
yhaliaw
marked this pull request as draft
September 1, 2026 05:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
ScaleSetClient.Domaps both 401 and 403 onto the bareErrUnauthorizedsentinel:GitHub answers 403 for a secondary rate limit and for SSO enforcement on an organization. Neither says anything about the credentials, and both clear on their own (or with an operator action that is not a credential rotation). Callers reading
ErrUnauthorizedtherefore conclude the credentials are dead when they are usually fine and the refusal is temporary.GARM does this to itself. In
workers/scaleset/scaleset.go, a failedRemoveRunneris read as:A secondary rate limit that would have cleared on the next pass is enough to reach that branch today, and enough to trigger the deactivation once the TODO is implemented.
It also affects API clients:
handleErrormapsErrUnauthorizedto 401 and deliberately stripsDetails, so a consumer sees a bare 401 with no body for what may have been a transient 403.The change
Return a
ForbiddenErrorfor 403, added tointernal/errorsalongsideRunnerTransitionErrorand following its shape: it reports as arunnerErrors.UnauthorizedErrorviaIs, exactly asRunnerTransitionErrorreports as aBadRequestError.That keeps this backward compatible. Every existing caller that only asks "was this refused?" —
scaleset.go,scaleset_listener.go,runner.go,pool.go— keeps working with no change. Callers that need to act on the difference can single it out witherrors.Is(err, &internalErrors.ForbiddenError{}), checked before the broader unauthorized case.handleErrorgains aForbiddenErrorcase mapping to HTTP 403, placed before the unauthorized case for that reason, so API clients can tell the two apart too. Response shape is unchanged (APIErrorResponse), and the swagger blocks documentdefault: APIErrorResponserather than enumerating statuses, so no docs or generated clients change.Both branches now also keep the response body and URL, as every other status branch in
Doalready does. Previously they returned a bare sentinel, so an operator reading the log had nothing to tell a revoked credential from a rate limit.Deliberately not included
Acting on the new distinction.
scaleset.go's handler is the obvious consumer, but changing when a scale set gets parked or deactivated is a behaviour change that deserves its own PR — this one only makes the information available. Happy to follow up if you'd like it in the same change.Notes
ForbiddenErrorininternal/errorsto match the existing precedent, which means external Go consumers of GARM as a library cannot reference the type (they get the HTTP 403 instead). If you would rather it were public, it could go togarm-provider-common/errorsnext toUnauthorizedError— happy to redo it that way.Isordering is load-bearing: becauseForbiddenErrorreports as anUnauthorizedError, anyswitch/casechain that checks unauthorized first will swallow it. That is called out in the type's doc comment and in thehandleErrorcomment.Testing
make testequivalent (go test -race -mod=vendor -tags testing -timeout=60m -parallel=4 -count=1 ./...) passes.golangci-lint run --build-tags=testing,integration(v2.10.1, as pinned in the Makefile) reports 0 issues.internal/errors/errors_test.gocovers the identity and compatibility guarantee, including that it survives%wwrapping and that a plain 401 is not mistaken for a 403.util/github/scalesets/client_test.gocovers the status mapping through a realhttptestserver, that 403 staysErrUnauthorized-compatible, that both refusals keep their body, and that 400/404/409 are unaffected.case 401, 403behaviour rather than passing vacuously.