Skip to content

Report non-credential 403s as upstream_forbidden with the upstream message - #2257

Closed
shardulbee wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
shardulbee:upstream-forbidden-not-rejected
Closed

shardulbee wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
shardulbee:upstream-forbidden-not-rejected

Conversation

@shardulbee

@shardulbee shardulbee commented Oct 10, 2026 •

Copy link
Copy Markdown

Summary

An upstream 403 does not always mean bad credentials. An API also sends 403 when the account's plan does not include a feature. Executor reported every such 403 as connection_rejected and told the caller to re-authenticate. The upstream's reason was only in details, and MCP text results show only code: message, so the caller did not see it.

This change makes a 403 a credential failure only when the response says so. Other 403s fail with upstream_forbidden and the upstream's message. This matches v2, where a bare 403 is rejected ("does not prove invalid credentials") and the upstream's error goes with it.

 on(upstream 401 or 403)
   if 403 and scope is insufficient        # unchanged
     return oauth_scope_insufficient
+  if 401, or 403 with a credential signal  # WWW-Authenticate challenge,
+                                           # invalid_token / UNAUTHENTICATED code,
+                                           # or "invalid API key" / "token expired" text
     return connection_rejected "HTTP 401: <upstream message>. Re-authenticate…"
+  else
+    return upstream_forbidden "HTTP 403 (forbidden): <upstream message>. Credentials were not reported as invalid…"

The check is isCredentialRejection in @executor-js/sdk/core, next to detectInsufficientScope. The OpenAPI and GraphQL plugins use it.

Linked issue

Related to #2126 (Cloudflare challenge 403s shown as rejected credentials, closed for v1). That 403 has no credential signal, so with this change it is also upstream_forbidden, not connection_rejected.

Verification

Before: an MCP invoke of an operation whose upstream returns 403 {"message":"feature not available on current billing plan"}:

Error: connection_rejected: Upstream rejected credentials for "<integration>" with HTTP 403. Re-authenticate or update the connection "<connection>" before retrying this tool.

After:

Error: upstream_forbidden: Upstream "<integration>" refused this request with HTTP 403 (forbidden): feature not available on current billing plan. The connection's credentials were not reported as invalid; check the account's plan, permissions, or the resource itself.
  • e2e scenarios/upstream-forbidden.test.ts (new) passes on selfhost and in CI. Through MCP invoke on one connection: a write succeeds; the plan-gated 403 text has the upstream message and HTTP 403, and has no "re-authenticate"; a 401 still says "Re-authenticate" with the upstream's reason.

  • e2e scenarios/oauth-scope-insufficient.test.ts: its plain PERMISSION_DENIED 403 now expects upstream_forbidden with the upstream message. Passes on selfhost.

  • upstream-failures.test.ts (OpenAPI): with the old backing.ts, 4 of the new tests fail. With this change, all 19 pass. The GraphQL plugin and core tests cover the same cases.

  • CI skips Format, Lint, Typecheck and Test on fork PRs, so I ran them locally:

  • bun run format:check

  • bun run lint

  • bun run typecheck: ran tsgo --noEmit for core/sdk, plugin-openapi, plugin-graphql and e2e only; the full turbo run needs more memory than the machine had.

  • bun run test: ran the full vitest suites for core/sdk, plugin-openapi and plugin-graphql only: 967, 340 and 120 tests pass.

  • e2e: upstream-forbidden, oauth-scope-insufficient (selfhost). The failing cloud shard is first-party-oauth.test.ts, which fails the same way on main.

Merge danger

Door: two-way. No storage or contract shape changes; revert restores the old labels.

Blast radius: callers that branch on connection_rejected for a 403. Those 403s now come back as upstream_forbidden unless the response names a credential problem. Reactive token refresh is not affected: it only runs on connection_rejected with status 401.

Checklist

  • Added a changeset.
  • Added or updated tests for the new behaviour.
  • No secrets, credentials, or private data in the diff.

@shardulbee

shardulbee commented Oct 10, 2026 •

Copy link
Copy Markdown
Author

Closing this. Why:

  • v2 already does this. In v2, httpProviderError reports a bare 403 as rejected, not unauthorized. The caller sees "Service rejected the request… A forbidden HTTP response alone does not prove invalid credentials", with the upstream's own error attached (openapi-request.ts, providerErrorDetail). So the bug exists only on v1.
  • v1 issues of this kind are being closed. [bug] Cloudflare bot challenges are reported as rejected credentials #2126, the same mislabel for Cloudflare-challenge 403s, was closed on 2026-10-08 while clearing the backlog before the v2 launch.
  • The cost doesn't fit that plan. The fix adds a new failure code (upstream_forbidden) on v1 and changes how existing callers see some 403s.

@shardulbee shardulbee closed this Oct 10, 2026
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