Skip to content

Fixed automatic persisted query requests being answered with the schema - #633

Open
xperiandri wants to merge 3 commits into
devfrom
apq-not-supported
Open

xperiandri wants to merge 3 commits into
devfrom
apq-not-supported

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Problem

Apollo Client's persisted query link first sends only a hash of the query in extensions.persistedQuery. It falls back to the full query once the server answers PersistedQueryNotSupported. The HTTP handler ignored the extension, so the client never got the error it expects:

  • a GET request was answered with the introspection result;
  • a POST request without a query got a 400 "Invalid JSON body";
  • a request with both the query and the hash was executed.

Changes

  • The answer. A request whose extensions.persistedQuery is truthy, with or without a query, is answered as Apollo Server 5.5.1 answers with persisted queries disabled (requestPipeline.ts, internalErrorClasses.ts):

    • HTTP 200;
    • Cache-Control: private, no-cache, must-revalidate;
    • a body holding only {"errors":[{"message":"PersistedQueryNotSupported","extensions":{"code":"PERSISTED_QUERY_NOT_SUPPORTED"}}]}.

    The body has no documentId: Apollo Client 4 does not fall back from a result with any other top-level member than data, errors and extensions.

  • Where it is checked: in a JSON body, in a multipart operations field, and in the extensions query string parameter of a GET request. Truthiness follows JavaScript, as Apollo Server tests it, and the last of duplicate members counts.

  • No copy of the extensions. An internal envelope reads them before GQLRequestContent, so the public request type is unchanged. A converter evaluates persistedQuery straight from the JSON reader and skips every other value. A 20 MB extensions value costs 416 KB per request, about the same as any unknown member; binding it as a JsonElement cost 20 898 KB.

  • WebSocket. graphql-transport-ws subscribe payloads are not checked: they must carry the query, they are never answered with introspection, and GraphQLWsLink sends the full query along with the hash.

  • Breaking: a request with both a query and a persisted query hash is now rejected instead of executed, as Apollo Server does.

Tests

The tests are xUnit, in RequestHandlerExtensibilityTests.fs:

  • a hash alone, or with the query, by POST, GET and multipart, answered exactly as above, with no other top-level member;
  • truthy and falsy persistedQuery values, with duplicate and nested members;
  • GET extensions that ask for nothing or are not JSON, still answered with the introspection result;
  • normal queries, multipart operations and introspection, unchanged.

An independent review checked the following:

  • the answer against Apollo Server's source;
  • Apollo Client's fallback, with its own check function on the exact bodies;
  • 16 truthiness values against JavaScript;
  • 76 handler and 15 Kestrel scenarios against dev, including bodies up to 20 MB, size limits and file uploads.

The only differences from dev were the intended ones.

Verification:

  • Unit tests: the whole project passes, 800 tests with 5 skipped.
  • FAKE: the full pipeline passes, including the integration tests.

🤖 Generated with Claude Code

xperiandri and others added 2 commits October 3, 2026 13:10
Apollo Client's persisted query link first sends only a hash of the query in
extensions.persistedQuery and falls back to the full query once the server
answers PersistedQueryNotSupported. The HTTP handler ignored the extension:
such a GET request was answered with the introspection result, a POST request
without a query with a 400 "Invalid JSON body" problem, and a request carrying
both was executed, so the client never received the error it expects.

- Read the extensions through an internal GQLRequestEnvelope before binding
  GQLRequestContent, so the public request type is unchanged, and check the
  extensions query string parameter of GET requests.
- Answer a request whose extensions.persistedQuery is truthy, with or without
  a query, as Apollo Server 5.5.1 does with persisted queries disabled
  (requestPipeline.ts, internalErrorClasses.ts): HTTP 200, Cache-Control
  "private, no-cache, must-revalidate", a single PersistedQueryNotSupported
  error with extensions.code PERSISTED_QUERY_NOT_SUPPORTED and no data.
- Left graphql-transport-ws subscribe payloads unchecked: they must carry the
  query, are never answered with introspection, and GraphQLWsLink sends the
  full query together with the operation's extensions.
- Breaking change: a request with both a query and a persisted query hash is
  now rejected instead of executed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g back

- The answer to a persisted query request is now {"errors": [...]} alone,
  as Apollo Server writes it: GQLResponse.RequestError added documentId, and
  Apollo Client 4 takes a body with a top-level member other than data,
  errors and extensions for no GraphQL result, so its persisted query link
  neither fell back to the full query nor disabled persisted queries.
- The extensions of a request are no longer bound as a JsonElement, which
  copied them: a client could make every request allocate a copy of a body
  as large as the server accepts (20 898 KB per request for a 20 MB body,
  now 416 KB, as for any unknown member). PersistedQueryRequest and its
  converter evaluate the truthiness of persistedQuery from the reader and
  skip every other value; the GET extensions parameter goes through the same
  converter.
- Wrapped the doc comments with tags in <summary>.
- Tests now check that the answer has no other member than errors, the
  truthiness of more values, duplicate and nested members, and GET
  extensions that do not ask for a persisted query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 09:44

Copilot AI 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.

🟡 Changes recommended

The implementation deserializes every non-GET request twice, adding avoidable CPU and multipart allocation overhead.

1 open finding
What changed in this PR

Adds Apollo APQ detection so unsupported persisted-query requests receive the expected fallback response.

Changes:

  • Detects truthy extensions.persistedQuery values across GET, JSON, and multipart requests.
  • Returns Apollo-compatible error status, body, and cache header.
  • Adds coverage and release notes for the behavior.
File Description
GraphQLRequestHandler.fs Implements APQ detection and rejection.
RequestHandlerExtensibilityTests.fs Tests APQ and normal request behavior.
RELEASE_NOTES.md Documents the fix and breaking behavior.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


let checkAnonymousFieldsOnly (ctx : HttpContext) = taskResult {
// Read ahead of the request itself, which cannot be bound without the query a persisted query request leaves out
let! envelope = ctx.TryBindJsonAsync<GQLRequestEnvelope>(GQLRequestContent.expectedJSON)
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 44s ⏱️
  908 tests   903 ✅  5 💤 0 ❌
2 724 runs  2 709 ✅ 15 💤 0 ❌

Results for commit e5c058e.

♻️ This comment has been updated with latest results.

The tests take the error message, its code, the cache directive and the
query string parameter from `PersistedQueries` and the media type from
`MediaTypeNames` instead of spelling them out again, and assert with
`Assert.Equal` and `Assert.Single` instead of `Assert.True` on a comparison.
The error extensions are built with `kvpObj` and without an upcast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

2 participants