Skip to content

linear: a root field's refused argument is answered ahead of every resolver's error - #1063

Merged
nuriyoo merged 281 commits into
mainfrom
linear-a-refused-root-argument-is-answered-first
Oct 30, 2026
Merged

nuriyoo merged 281 commits into
mainfrom
linear-a-refused-root-argument-is-answered-first

Conversation

@nuriyoo

@nuriyoo nuriyoo commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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.run asks _refused_root_arguments which 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's Internal 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 __type and 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, as test_linear_orders_the_body_and_the_credential sends a WithTheKey row with each other credential. In tests/test_graphql.py, test_a_field_error_is_marked_by_who_made_it finds graphql-js's refusal of widget(id: $id) first, ahead of an earlier boom's error.

Not in this PR. Which of a by-id root's not-found and a later root field's own refused arguments (sort with orderBy, first with last) 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, $x sent as null where the row names it:

query                                                                   real
query($x: String = "a") { a: issue(id: "00000000-…") { id }             Internal server error at ["b"], column 100,
  b: issue(id: $x) { id } }                                               data null, no error at ["a"]
the same with b first                                                   the same at ["b"], column 40
query($x: String = "a") { v: viewer { id } b: issue(id: $x) { id } }    the same at ["b"], column 57, viewer's data gone
… a: issue(…) { id } ...F } fragment F on Query { b: issue(id: $x) }    the same at ["b"], column 129
query($x: String = "00000000-…") { b: issue(id: $x) { id } }, no        Entity not found: Issue at ["b"], the default read
  variables
2026-10-26: … t: __type(name: $y) { name } b: issue(id: $x) { id }      Internal server error at ["t"], then at ["b"],
  with both sent null                                                     data null
a: issue(…) b: issue(id: $x) c: team(id: $x), $x null                   the same at ["b"] alone
2026-10-29: query($y: String = "Issue") { a: issue(id: "00000000-…")     Internal server error at ["t"], column 107, then
  { id } t: __type(name: $y) { name } }, $y null                          Entity not found: Issue at ["a"], data null
… u: __type(name: $z) after t, $z null too                              the same at ["t"], then at ["u"], then ["a"]
… s: __type(name: "Team") { name } between a and t                      the same at ["t"], then ["a"]
query($y: String = "Issue") { t: __type(name: $y) { name } }, $y null   Internal server error at ["t"], column 47,
                                                                          data {"t": null}

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 __type is, nulls the data, and one at __type leaves 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

  • Tests — pytest on uv sync --all-extras --locked plus uv pip install --no-deps "llama-index-readers-hubspot<0.6", as CI installs: 8941 passed, 1 skipped.
  • Optional surfaces that ran rather than skipped — every one but the Atlassian MCP test in tests/test_mcp.py, which needs Docker's Linux containers and found no daemon here.
  • Lint — ruff check . && ruff format --check .: clean.

At f39a1314, on fireflies-each-answer-carries-fireflies-headers-and-budget at 998147eb.

uv sync --all-extras --locked
uv pip install --no-deps "llama-index-readers-hubspot<0.6"
uv run --no-sync pytest -rs                     8941 passed, 1 skipped in 49.48s
ruff check . && ruff format --check .           All checks passed! / 195 files already formatted
python scripts/prose_check.py <the base>        exit 0
mutations of each line this branch adds         45 (6 by hand), 37 caught
each row against the mutation written for it    24 of 24 rows lose to one; 0 missed

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 __type for. The eight survivors cannot change an answer. None for [] or False in what _refused_root_arguments returns reads alike, and if argued as if True leaves 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 __type on another root before the walk, and located_error keeps the node get_argument_values gave a refusal.

  • A test fails without this change — test_linear_orders_the_body_and_the_credential, test_a_field_error_is_marked_by_who_made_it.
  • A new endpoint serving corpus content is ACL-scoped, proved for both an admin and a scoped token — no new endpoint or way in; the check refuses before any resolver reads the store.
  • Comments state what was measured, not the history of the fix

…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.
…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
…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
…'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
… 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
@nuriyoo nuriyoo added the fidelity The vendor API defines the right answer; closing this needs a measurement against it label Oct 26, 2026
@nuriyoo nuriyoo self-assigned this Oct 26, 2026
…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
…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 khj809 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.

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.

…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
…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
…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
@nuriyoo
nuriyoo merged commit 257bc73 into main Oct 30, 2026
9 checks passed
@nuriyoo
nuriyoo deleted the linear-a-refused-root-argument-is-answered-first branch October 30, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fidelity The vendor API defines the right answer; closing this needs a measurement against it

Projects

None yet

2 participants