Skip to content

fix: prevent panic classifying errors with a target-inspecting Is method#10

Merged
basgys merged 1 commit into
mainfrom
fix/rejected-error-panic
Jun 18, 2026
Merged

fix: prevent panic classifying errors with a target-inspecting Is method#10
basgys merged 1 commit into
mainfrom
fix/rejected-error-panic

Conversation

@basgys

@basgys basgys commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Problem

AdaptiveThrottle classifies the throttled function's returned error with a value-based guard:

case errors.Is(err, errRejected{}):

The errRejected{} target has a nil inner, and errRejected.Error() dereferences it:

func (err errRejected) Error() string { return err.inner.Error() } // nil deref when inner == nil

errors.Is walks the returned error's chain and calls each link's Is(error) bool method with that target. Any error type whose Is inspects the target via Error() — e.g. error types that compare by message (e.message == target.Error()), common in third-party libraries — triggers errRejected{}.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 RejectedError involved. A bare errors.New error has no Is method, which is why it went unnoticed.

Stack:

errRejected.Error ← (message-comparing).Is ← errors.Is ← Throttle (adaptive.go)

Note the asymmetry: an error wrapped in RejectedError does not panic — errRejected.Is short-circuits errors.Is before the chain reaches the offending link. Only the bare, unwrapped error detonates.

Fix

  1. Detect the wrapper by type, not value. Use errors.AsType[errRejected] (v2) / errors.As (v1) — type-based matching never calls Error() on a target. This is the idiomatic guard and is robust to any conforming error type flowing through the throttle.
  2. RejectedError(nil) returns nil. Wrapping "no error" as a rejection is meaningless; it was also the only way to construct an errRejected with a nil inner. Returning nil restores the invariant that inner is 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:

  • bare target-inspecting error → no panic, returned unchanged (a bare error is not a rejection)
  • same error wrapped in RejectedError → classified as rejection, unwrapped to the original for the caller
  • RejectedError(nil) → returns nil, accounted as success

go test -race ./... and go vet pass 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 RejectedError handling.

Overview
Fixes a panic when adaptive throttle classifies returned errors that use Is methods comparing target.Error() (common in third-party libraries). Classification no longer uses errors.Is(err, errRejected{}), which walked the chain with a nil-inner errRejected{} target and could nil-dereference in Error().

RejectedError wrappers are detected with type-based matching (errors.As in v1, errors.AsType[errRejected] in v2), unwrapped for the caller, and counted as rejections explicitly—same behavior without the old fallthrough.

RejectedError(nil) now returns nil so successful calls are not treated as rejections and errRejected.inner stays 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.

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
basgys force-pushed the fix/rejected-error-panic branch from b772861 to 610f00e Compare June 18, 2026 11:51
@basgys
basgys merged commit d8cdf3d into main Jun 18, 2026
3 checks passed
@basgys
basgys deleted the fix/rejected-error-panic branch June 18, 2026 11:53
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.

1 participant