Skip to content

Classify throttling 403 responses as upstream_http_error - #2435

Open
Tyagiquamar wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
Tyagiquamar:fix/2420-throttling-403
Open

Tyagiquamar wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
Tyagiquamar:fix/2420-throttling-403

Conversation

@Tyagiquamar

@Tyagiquamar Tyagiquamar commented Oct 29, 2026 •

Copy link
Copy Markdown

Summary

A 403 that means "you are going too fast" is now reported as upstream_http_error with the upstream message intact, instead of connection_rejected with a re-authenticate prompt.

Root cause

In packages/plugins/openapi/src/sdk/backing.ts, every non-2xx 401/403 enters the credential branch and becomes connection_rejected unless it names a scope shortfall. Google reports per-user quotas as HTTP 403 (rateLimitExceeded, RESOURCE_EXHAUSTED), and GitHub's secondary rate limit is also a 403 with Retry-After, so a burst of Gmail users.messages.get calls told the agent its credentials were bad. Re-authenticating cannot clear a quota, and the upstream reason was replaced with re-auth guidance.

Technical solution

  • detectRateLimited checks two structural signals:
    • throttling headers: a non-empty Retry-After, or x-ratelimit-remaining: 0;
    • an exact rate-limit value (rateLimitExceeded, userRateLimitExceeded, rate_limit_exceeded, RESOURCE_EXHAUSTED, too_many_requests) in an error, reason, status, or code field at any depth (bounded at 6 levels).
  • A 403 that matches falls through to the ordinary upstream_http_error path, so the upstream message and status survive.
  • Prose alone never classifies. A permission 403 without those signals still gets connection_rejected, and scope shortfalls still get oauth_scope_insufficient because that branch only runs when the response is not throttling.

Tests

Added to packages/plugins/openapi/src/sdk/upstream-failures.test.ts:

  • rate-limit 403 (Google RESOURCE_EXHAUSTED) is upstream_http_error: Google's nested error.errors[].reason: rateLimitExceeded body now yields upstream_http_error, status 403, message "Rate Limit Exceeded".
  • throttling 403 with Retry-After is upstream_http_error: GitHub's secondary-rate-limit body with Retry-After: 60 yields upstream_http_error and keeps the message.
  • The existing ordinary 403 without a scope signal stays connection_rejected and both scope-insufficient 403 tests still pass.

Commands run in packages/plugins/openapi:

  • bun run test -- src/sdk/upstream-failures.test.ts (18 passed)
  • bun run test -- src/sdk/upstream-failures.test.ts -t "403" (6 passed, includes the false-positive guard)
  • bun run typecheck (clean)
  • bunx oxlint -c .oxlintrc.jsonc <changed files> --deny-warnings (0 warnings, 0 errors)
  • bunx oxfmt --check <changed files> (clean)

Note: the Format, Lint, Typecheck, Test, and Changed paths jobs in ci.yml are gated to same-repo branches, so they report skipping on a fork PR. The local runs above are the equivalent evidence.

Changeset added at .changeset/throttling-403-classification.md (patch, @executor-js/plugin-openapi).

Fixes #2420

A 403 that names a rate limit is a quota signal, not a credential failure.
The 401/403 branch turned it into connection_rejected, which tells the agent
to re-authenticate and drops the upstream reason (Gmail falls over on a burst
of parallel users.messages.get calls, UsefulSoftwareCo#2420).

Detect throttling structurally: Retry-After or x-ratelimit-remaining: 0, or an
exact rateLimitExceeded / RESOURCE_EXHAUSTED / too_many_requests value in an
error, reason, status, or code field. Throttling 403s now fall through to the
ordinary upstream_http_error path and keep the upstream message; permission
403s still get connection_rejected, and scope shortfalls still get
oauth_scope_insufficient.

This branch has not been deployed

No deployments
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.

Google 403 rate limits are reported as connection_rejected

1 participant