Repository navigation
linear: a root field's refused argument is answered ahead of every resolver's error - #1063
Merged
nuriyoo merged 281 commits intoOct 30, 2026
Merged
Conversation
…the-http-layer-is-apollos-and-expresss
…aphql-each-path-is-routed-as-the-vendor-routes-it
…connection both vendors closed The merge from the HTTP-layer branch sends `Connection: close` with the 400, which the shared Cloudflare test asserts. Measured on 2026-10-26 over raw HTTP/1.1: api.linear.app and api.fireflies.ai sent `Connection: close` with the 400 for `POST /graphql%zz`, `POST /a%00b` and `GET /a%2` and answered no second request on the connection.
…ection-and-401-are-linears
…the-http-layer-is-apollos-and-expresss
…D body Fastify does not read, and a broken escape under a longer name
Measured on 2026-10-25 and 2026-10-26 (UTC): api.linear.app answered `PUT /` and `PATCH /` with
Express's 404 quoting `/`, and api.fireflies.ai answered a `PROPFIND` to `/graphql/` under
`a/b/c` and with `{` as JSON with Fastify's 404, where a `POST` under `a/b/c` drew its 415.
…aphql-each-path-is-routed-as-the-vendor-routes-it
…ent of the one it leaves unread
…ollo Server writes ends in a newline Express's answers carry `Cache-Control: private, no-cache, no-store, must-revalidate`, `Vary`, an `x-request-id`, Express's weak `ETag` (a fixed one on the refusal of an API key under `Bearer`), and `cors`'s `Access-Control-Allow-Origin` and `Access-Control-Expose-Headers` for a request with an `Origin`; the greeting, the methods `/` takes and the 404 page carry `nosniff`, as each answer at `/` does, and the 404 page its `Content-Security-Policy`. An answer to a document Linear ran carries `x-ratelimit-requests-limit`, `-remaining` and `-reset` from a bucket refilling at 2,500 an hour under an API key and 1,200 without one. Apollo Server's `PersistedQueryNotFound` carries its `Cache-Control` on both vendors. Read off 1,058 answers api.linear.app gave on 2026-10-25 and 2026-10-26 (UTC).
…t, Origin's tab and U+00A0, the bucket's rounding and the address a caller with no credential is counted by
…py ahead of a value left off
…f the measurement
…'s minute and day are spent and refused as Fireflies spends them Fastify's answers carry `Access-Control-Allow-Origin: *` and every answer past Cloudflare `Strict-Transport-Security`; an answer of Apollo Server's request pipeline carries `Cache-Control: no-store`, and the JSON Apollo Server writes ends in a newline. Each root field a document runs spends one of a key's 30 a minute and 500 a day, which the answer reports in `x-ratelimit-*`; a field past either is null beside a `too_many_requests` error, with `retry-after`, unless `BACKLOT_FIREFLIES_ENFORCE_RATE_LIMITS` is off. Telemetry classes `too_many_requests` as a rate limit. Measured against api.fireflies.ai on 2026-10-25 and 2026-10-26 (UTC).
…h documents spend the budget, its boundaries and its count with the refusal off
…erces them, and rows for a variable a field refuses, a default @include reads and an error a refused field made
…he engine's docstring says each router resolves and runs apart
…ay's refusal says why it is unmeasured
… an earlier root field's resolver graphql-js reads each root field's arguments as it starts the field, before any of Linear's asynchronous resolvers answers, so a `null` sent for a later root field's non-null argument is answered alone with the data null, where Backlot's synchronous run answered the earlier field's resolver error first. Measured against api.linear.app on 2026-10-21 and 2026-10-26 (UTC).
…d rows for a fragment and a variable's default
…ounts as graphql-js counts Fireflies runs graphql-depth-limit (maxDepth 10) as graphql-js starts validating: its depth errors come ahead of graphql-js's own and count toward the hundred validate takes, and it throws, which Apollo Server answers with its 500, on a spread of an undefined fragment, a field-less spread cycle and a 101st report. That replaces the walk for undefined fragments, which answered the 500 under __schema, __type and __typename and for operations a later one replaces. A column counts UTF-16 code units, and each error the run makes, a resolver's too, is located as graphql-js locates it. The refusal after fifty refused variables keeps INTERNAL_SERVER_ERROR, which Linear masks as its Internal server error.
…ection-and-401-are-linears # Conflicts: # tests/test_linear.py
…the-http-layer-is-apollos-and-expresss # Conflicts: # backlot/graphql/engine.py # backlot/routers/fireflies.py
…aphql-each-path-is-routed-as-the-vendor-routes-it
…to linear-each-answer-carries-linears-headers # Conflicts: # tests/test_linear.py
…to linear-each-answer-carries-linears-headers
…ies-each-answer-carries-fireflies-headers-and-budget
…routed-as-the-vendor-routes-it
…get' into linear-a-refused-root-argument-is-answered-first
…resh's pattern reads as it reads what comes before api.linear.app answered a GET with its own ETag in If-None-Match on 2026-10-29 (UTC) with a 200 where no-cache was followed by U+00A0 or by a space and a comma, and with a 304 where it was followed by U+0085. No row sent anything after no-cache, so the pattern without its trailing class left the table green.
…ies-each-answer-carries-fireflies-headers-and-budget
…get' into linear-a-refused-root-argument-is-answered-first
khj809
approved these changes
Oct 30, 2026
khj809
left a comment
Contributor
There was a problem hiding this comment.
Re-checked at f39a1314: every finding of the earlier review is addressed. The refused nullable __type after an unknown issue, alone, and with a served __type between them each answer as api.linear.app does on 2026-10-30, errors, locations and data alike.
…routed-as-the-vendor-routes-it
…he-vendor-routes-it' into linear-each-answer-carries-linears-headers # Conflicts: # tests/test_linear.py
…rs-headers' into fireflies-each-answer-carries-fireflies-headers-and-budget # Conflicts: # tests/test_fireflies.py
…reflies-headers-and-budget' into linear-a-refused-root-argument-is-answered-first
…routed-as-the-vendor-routes-it
…to linear-each-answer-carries-linears-headers
…ies-each-answer-carries-fireflies-headers-and-budget The conflict is the docstring of test_fireflies_an_id_naming_nothing_answers_with_reals_error: main's channel rows joined to this branch's answers, which end in a newline. The two channel rows main added expect their bytes with that newline here.
…get' into linear-a-refused-root-argument-is-answered-first
…routed-as-the-vendor-routes-it
…to linear-each-answer-carries-linears-headers
…ies-each-answer-carries-fireflies-headers-and-budget
…get' into linear-a-refused-root-argument-is-answered-first
…he-vendor-routes-it' into linear-each-answer-carries-linears-headers
…ies-each-answer-carries-fireflies-headers-and-budget
…get' into linear-a-refused-root-argument-is-answered-first
Base automatically changed from
fireflies-each-answer-carries-fireflies-headers-and-budget
to
main
October 30, 2026 10:16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #918. It is stacked on Fireflies' headers and budget, which is #1062. A root field's argument graphql-js refuses is answered ahead of every resolver's error, as api.linear.app answers it, and one at a non-null field is answered alone, with the data null.
What changed
The engine (
backlot/graphql/engine.py). Before it runs an operation,Engine.runasks_refused_root_argumentswhich root fields' arguments graphql-js would refuse as it starts each field, in the order the fields come, up to the first non-null one. Where there is a non-null one, those errors are the answer, with the data null, and the router writes each as Linear'sInternal server error; otherwise the run answers, those errors ahead of its own and in place of its errors at the same fields. On Fireflies, whose root fields are each nullable, the run always answers.Tests. Twelve rows in
_LINEAR_BODIES(tests/test_linear.py) hold a refused argument alone or beside other root fields,viewer, a fragment, a served__typeand a refused one among them, and an argument read from its variable's default, each answered as real answered it with the key. Without one each is Linear's 401, astest_linear_orders_the_body_and_the_credentialsends aWithTheKeyrow with each other credential. Intests/test_graphql.py,test_a_field_error_is_marked_by_who_made_itfinds graphql-js's refusal ofwidget(id: $id)first, ahead of an earlierboom's error.Not in this PR. Which of a by-id root's not-found and a later root field's own refused arguments (
sortwithorderBy,firstwithlast) Linear answers is #1067.Why this is what the real API does
Measured. api.linear.app with a personal API key on 2026-10-21, 2026-10-26 and 2026-10-29 (UTC), each request with its own cache-buster,
$xsent asnullwhere the row names it:graphql-js starts each root field in order and Linear's resolvers answer asynchronously, so a refused argument at a later field is thrown before an earlier field's resolver answers; a refusal at a non-null root field, as each of Linear's but
__typeis, nulls the data, and one at__typeleaves the run to answer behind it. Backlot's resolvers answer synchronously, and the check before the run restores graphql-js's order. Each of the twelve rows' bodies is the one real answered, byte for byte.Verification
pytestonuv sync --all-extras --lockedplusuv pip install --no-deps "llama-index-readers-hubspot<0.6", as CI installs: 8941 passed, 1 skipped.tests/test_mcp.py, which needs Docker's Linux containers and found no daemon here.ruff check . && ruff format --check .: clean.At
f39a1314, onfireflies-each-answer-carries-fireflies-headers-and-budgetat998147eb.Each line this branch adds was mutated against each test file it or its base changes, and more by hand on the order of the errors, the data a refused non-null field leaves, a refusal's path and nodes, the variables the walk reads and what it takes
__typefor. The eight survivors cannot change an answer.Nonefor[]orFalsein what_refused_root_argumentsreturns reads alike, andif arguedasif Trueleaves the run's errors as they were. Each error the run makes at a root field has a path, and it makes one at each field whose refusal is answered. Validation refuses__typeon another root before the walk, andlocated_errorkeeps the nodeget_argument_valuesgave a refusal.test_linear_orders_the_body_and_the_credential,test_a_field_error_is_marked_by_who_made_it.