fix: prevent panic classifying errors with a target-inspecting Is method#10
Merged
Conversation
AdaptiveThrottle classified the throttled function's error with a
value-based guard, errors.Is(err, errRejected{}). The errRejected{}
target has a nil inner, and errRejected.Error() dereferences inner.
errors.Is walks the returned error's chain and invokes each link's
Is(error) bool method with that target. Any error type whose Is
inspects the target via Error() (e.g. message-comparing error
libraries) thus triggers errRejected{}.Error() -> nil deref -> panic.
This fires on the first call for ordinary non-rejection errors (bad
request, auth, validation) that happen to be such a type; stdlib
errors.New errors have no Is method, which is why it went unnoticed.
Detect the wrapper by type instead (errors.As / errors.AsType), which
never calls Error() on the target. Also make RejectedError(nil) return
nil: wrapping a nil error as a rejection is meaningless and was the
only way to construct an errRejected with a nil inner, so this restores
the invariant that inner is never nil and a successful call is never
accounted as a rejection.
Applied to both v1 (root module) and v2.
basgys
force-pushed
the
fix/rejected-error-panic
branch
from
June 18, 2026 11:51
b772861 to
610f00e
Compare
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.
Problem
AdaptiveThrottleclassifies the throttled function's returned error with a value-based guard:The
errRejected{}target has a nilinner, anderrRejected.Error()dereferences it:errors.Iswalks the returned error's chain and calls each link'sIs(error) boolmethod with that target. Any error type whoseIsinspects the target viaError()— e.g. error types that compare by message (e.message == target.Error()), common in third-party libraries — triggerserrRejected{}.Error()→ nil dereference → panic.This is not exotic: it fires on the first call, for ordinary non-rejection errors (bad request, auth, validation) that happen to be such a type, with no
RejectedErrorinvolved. A bareerrors.Newerror has noIsmethod, which is why it went unnoticed.Stack:
Note the asymmetry: an error wrapped in
RejectedErrordoes not panic —errRejected.Isshort-circuitserrors.Isbefore the chain reaches the offending link. Only the bare, unwrapped error detonates.Fix
errors.AsType[errRejected](v2) /errors.As(v1) — type-based matching never callsError()on a target. This is the idiomatic guard and is robust to any conforming error type flowing through the throttle.RejectedError(nil)returnsnil. Wrapping "no error" as a rejection is meaningless; it was also the only way to construct anerrRejectedwith a nilinner. Returningnilrestores the invariant thatinneris never nil and ensures a successful call is never accounted as a rejection.Applied to both the v1 (root) module and v2.
Tests
Per module:
RejectedError→ classified as rejection, unwrapped to the original for the callerRejectedError(nil)→ returnsnil, accounted as successgo test -race ./...andgo vetpass for both modules.Note
Medium Risk
Touches core error-classification paths on every throttled call; fix is targeted and well-tested but changes rejection accounting for explicit
RejectedErrorhandling.Overview
Fixes a panic when adaptive throttle classifies returned errors that use
Ismethods comparingtarget.Error()(common in third-party libraries). Classification no longer useserrors.Is(err, errRejected{}), which walked the chain with a nil-innererrRejected{}target and could nil-dereference inError().RejectedErrorwrappers are detected with type-based matching (errors.Asin v1,errors.AsType[errRejected]in v2), unwrapped for the caller, and counted as rejections explicitly—same behavior without the oldfallthrough.RejectedError(nil)now returnsnilso successful calls are not treated as rejections anderrRejected.innerstays non-nil when constructed.Mirrored in root and v2 with regression tests for message-comparing errors and nil wrapping.
Reviewed by Cursor Bugbot for commit 610f00e. Bugbot is set up for automated code reviews on this repo. Configure here.