Skip to content

Surface HTTP-level GraphQL failures by status, not as parse errors - #72

Merged
lukepiette merged 3 commits into
mainfrom
graphql-http-status-errors
Jul 31, 2026
Merged

Surface HTTP-level GraphQL failures by status, not as parse errors#72
lukepiette merged 3 commits into
mainfrom
graphql-http-status-errors

Conversation

@lukepiette

Copy link
Copy Markdown
Contributor

Summary

Completes for the GraphQL path what #64 did for the REST client. graphqlRequest called response.json() unconditionally with no response.ok check, so an HTTP-level failure — a 429 from the public rate limiter, a 5xx, a WAF page — surfaced as an opaque Unexpected token '<' parse error. That path serves every GraphQL-backed tool: list-gpu-types, list-data-centers, get-capacity (#71), list-hub-repos, list-public-endpoints, deploy-hub-repo, set-endpoint-gpus — and the public host is the credential-free (most rate-limited) one.

Non-OK responses now throw the same HttpError as the REST client: status and body named, with the RateLimit-header wait hint on 429 (reusing #64's rateLimitHint unchanged). One refinement: a non-OK body that still carries a GraphQL errors array (servers commonly 400 on malformed queries) keeps the readable GraphQL message with the status attached — GraphQL Error (HTTP 400): <message> — instead of dumping raw JSON.

Found during two-agent adversarial review of #71 (probe mode fans out up to 12 concurrent public GraphQL calls, multiplying exposure to exactly this failure). Kept as its own PR since it changes error behavior for every GraphQL tool. Merge-order note: independent of #71 — whichever lands second just works; with both merged, a rate-limited probe lands as a readable per-version probeErrors entry instead of an opaque one.

Test plan

  • 3 new goldens: 429 HTML body → HttpError naming the status with back-off guidance (not a parse error); 400 with GraphQL errors body → readable message + status; 200 with errors array → message unchanged (golden).
  • Full suite: 505 pass / 0 fail. Lint + type-check clean. Changeset (patch) included.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
runpod-mcp Ready Ready Preview Jul 31, 2026 5:45pm

Request Review

@lukepiette

Copy link
Copy Markdown
Contributor Author

Adversarial-review pass (independent subagent, live API probing included) applied in the second commit:

  • Fixed (medium): non-array errors in a non-OK JSON body ({"errors":"internal failure"} — a shape proxies/WAFs emit) passed the truthy .length check and threw TypeError: .map is not a function, losing status and body. Now Array.isArray-guarded with defensive item stringification.
  • Hardened: 401/429 with a GraphQL-shaped body now prefer HttpError so the re-auth and RateLimit hints are never suppressed; HttpError messages cap embedded bodies at 2 KB (.body keeps the full text) so a WAF error page can't flood agent context — this also benefits the REST client.
  • Tests: harness steps can now carry response headers, enabling goldens for the actual RateLimit-hint content ("minute";r=0;t=120 → named window + reset), plus non-array errors, 401 precedence, and truncation. 509 pass / 0 fail.
  • Changeset: no longer names get-capacity (lives on unmerged Add get-capacity: GPU capacity matrix across host CUDA versions #71).

Review also verified live against api.runpod.io/graphql: real 400s do carry GraphQL errors arrays (branch is live, not defensive), 5xx returns HTML (HttpError branch), authed 401 returns {"error":{}} (correctly falls to HttpError + re-auth hint), and the hosted onUnauthorized credential-invalidation path is unaffected (it observes status at the fetch seam, before body handling).

🤖 Generated with Claude Code

@justinwlin justinwlin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving with one fixup commit pushed (4071653), addressing the two review nits:

  • 401 hint scoped to credentialed call sites. The re-auth hint moved out of the HttpError constructor into the callers via the existing hint param — REST client, authed GraphQL, and the SSE log reader still send it; the credential-free public GraphQL path no longer advises rotating an API key it never sent (consistent with observeUnauthorized's existing rationale).
  • Non-OK-with-GraphQL-errors now throws HttpError (messages as body) instead of a plain Error, so .status/.name stay machine-readable. Message shape: Runpod GraphQL Error: 400 - <message>.
  • 4 new goldens pin hint presence/absence per path (REST/authed-GraphQL/SSE present, public-GraphQL absent) — previously the only 401-hint test asserted it on the public seam.

512 pass / 0 fail, type-check + prettier clean on touched files.

lukepiette and others added 3 commits July 31, 2026 13:38
graphqlRequest called response.json() unconditionally, so a 429/5xx
returning an HTML body surfaced as an opaque "Unexpected token '<'"
parse error. Non-OK responses now throw the same HttpError as the REST
client — status + body, RateLimit wait hint on 429 (reusing the #64
machinery) — while a non-OK body that still carries a GraphQL errors
array keeps the readable GraphQL message with the status attached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Array.isArray guard on the parsed errors field: proxies/WAFs emit
  {"errors":"..."} where a non-empty string passes a .length check and
  .map() then throws a TypeError worse than the parse error this change
  eliminates; item messages are stringified defensively too
- 401/429 with a GraphQL-shaped body now fall through to HttpError so
  the re-auth and RateLimit hints are never suppressed
- HttpError caps embedded bodies at 2KB in the message (.body keeps the
  full text) so a large WAF/proxy error page can't flood agent context
- Test harness steps can carry response headers; goldens added for the
  RateLimit-header hint content, non-array errors, 401 precedence, and
  body truncation
- Changeset no longer names get-capacity (lives on unmerged #71)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rrors throws HttpError

The re-auth hint was baked into the HttpError constructor, so the
credential-free public GraphQL path advised rotating an API key it never
sent. The hint now travels through the existing hint param from each
call site that actually carried a credential (REST client, authed
GraphQL, SSE log reader); the public path's 401 stays hint-free.

The non-OK-with-GraphQL-errors branch now throws HttpError (extracted
messages as the body) instead of a plain Error, keeping .status
machine-readable.

New goldens: REST 401 hint present, authed-GraphQL 401 hint present,
SSE 401 hint present, public-GraphQL 401 hint absent.
@lukepiette
lukepiette force-pushed the graphql-http-status-errors branch from 4071653 to 55c0250 Compare July 31, 2026 17:45
@lukepiette
lukepiette merged commit 3bc4d8e into main Jul 31, 2026
5 checks passed
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.

2 participants