Repository navigation
Description
cloudflare-export: GraphQL errors is assumed to be an array of {message: string} — other shapes throw TypeErrors misclassified as fetch-failed, and a single-object errors is silently ignored
Evidence (verified this run, main 819889b)
pm-stack/cloudflare-export/cloudflare-quota-export.mjs, in run():
- Line 407:
const gqlErrors = gql.body?.errors ?? []; - Line 408-409:
if (gqlErrors.length) { const msg = gqlErrors.map((e) => e.message.split("\n")[0]).join(" ; "); ... }— this assumeserrorsis an array whose every entry is an object with a stringmessage. Nothing validates that shape, in a file whose other parsers (parseGraphqlUsage,classifyR2,foldKv,firstSum) are deliberately fail-closed on shape drift. - Line 451 (
main()): any non-ExportErrorescapingrun()is re-wrapped asnew ExportError("fetch-failed", 7, "unexpected: ...").
Probe (laptop ~/git/fleet/probe-2026-10-30-laneB-cfgqlerr/probe.mjs, Bun, the real run() imported from the laptop clone — md5 0fbd8dae4f5eba83adb88ec6092d1039, byte-identical to sandbox main — with fetch stubbed; billing probes 200, buckets well-formed):
- Control, valid payload with no
errors:status: ok, 11 metrics. - Control,
errors: [{message: "auth: permission denied"}]: correctly throwsExportError token-scopeexit 3. errors: "upstream exploded"(a string): TypeError —gqlErrors.map is not a functionescapesrun();main()reclassifies it as fetch-failed / exit 7 with detailunexpected: ..., instead of the file's ownunparseable / exit 6for an unrecognised shape, and the GraphQL error text is lost.errors: [null]: TypeError —null is not an object (evaluating 'e.message'), same fetch-failed/7 misclassification.errors: [{message: 12345}]: TypeError —e.message.split is not a function, same misclassification.errors: {message: "auth: permission denied"}(single object, no array) with a validdatapayload alongside (GraphQL permits partial data + errors): the error is silently ignored —gqlErrors.lengthisundefined, so the branch never runs — andrun()publishes astatus: okdocument with 11 metrics despite an auth/permission error entry. Withoutdata, the same shape falls through to the generic "no account node in data" unparseable, and the token-scope classification (exit 3) the array form gets never fires.
Why it matters
This exporter's whole contract is its failure taxonomy (header comment, lines 36-40): token-scope (exit 3) tells the operator the token needs scopes, unparseable (6) says an endpoint answered an unknown shape, fetch-failed (7) says the network/other failed. A shape-drifted errors field currently lands in the wrong bucket — an on-call reader chasing a "fetch failure" finds a healthy network and no error text — and the single-object form can either hide an auth error behind a green status: ok document or strip it of its scope classification. The aggregator/gatherer side trusts these statuses to decide whether a Cloudflare reading is real.
Acceptance criteria
- The GraphQL
errorsvalue is normalised/validated before use: a non-arrayerrors, or entries that are not objects with a stringmessage, produceExportError("unparseable", 6, ...)naming the shape — never a bare TypeError escapingrun(). - A single-object
errors(or any truthy non-array form triage decides to accept) is not silently ignored: its message goes through the same auth/permission/scope classification as the array form, or it is rejected as unparseable — triage pins which; an auth/permission error must never coexist with a publishedstatus: okdocument. -
main()'s classification of a GraphQL shape problem isunparseable/6, notfetch-failed/7. - Regression tests in
cloudflare-quota-export.test.mjscover:errorsas a string,[null],[{message: <number>}], and a single-objecterrors, alongside the existing array controls (valid payload still exportsstatus: ok;[{message: "auth: ..."}]still yields token-scope/3). - Well-formed responses are unaffected: the existing cloudflare-export test suite passes unchanged.
Dedup: #2574 covers the R2 bucket enumeration in the same run() (fails-open r2Buckets: [] + pagination) — a different field and failure mode; this issue is only the GraphQL errors handling at lines 407-416. #2460 is the meta-exporter's timestamp misclassification, a different file.
Activity
dezren39 commentedon Oct 30, 2026
🤖 Agent note · filed
errorsshape handling in cloudflare-quota-export.mjs against the realrun()on the laptop (stubbed fetch, main 819889b).errorsshapes, then route a small_fix packet.dezren39 commentedon Nov 1, 2026
🤖 Agent note · picked up
dezren39 commentedon Nov 1, 2026
🤖 Agent note · done
199d9f2e2repo-health:v1:fix:developing-today/code:issue:2608)Triage findings — CONFIRMED
Fresh verification at current main
199d9f2e2(laptop primary clone, clean main):pm-stack/cloudflare-export/cloudflare-quota-export.mjs:407-413—const gqlErrors = gql.body?.errors ?? []; if (gqlErrors.length) { const msg = gqlErrors.map((e) => e.message.split("\n")[0]).join(" ; "); … }with the auth|permission|scope → token-scope classification and unparseable/6 fallback inside the branch. No shape validation anywhere.main()'s catch wraps any non-ExportErrorasfetch-failed/7 (unexpected: …), so the bare TypeErrors from drifted shapes land in the wrong taxonomy bucket with the real error text mangled.errorshas no.length→ the branch is skipped entirely → a validdatapayload publishesstatus: okwith the error invisible.The file is byte-identical between the filing probe's main (
819889bb) and current main (emptygit diff), so the probe carries verbatim (~/git/fleet/probe-2026-10-30-laneB-cfgqlerr/probe.mjs, realrun(), stubbed fetch): valid control → ok, 11 metrics; array auth error → token-scope/3; errors as string → TypeErrorgqlErrors.map is not a function;[null]→ TypeError one.message;[{message: 12345}]→ TypeErrore.message.split is not a function; single-object auth error + valid data → status ok (silently ignored).Pinned normalisation (task 5394)
errorswhosemessageis a string wraps to a one-element array and takes exactly the array form's classification (auth/permission/scope → token-scope/3; otherwise unparseable/6 with the message). Never ignored, never behindstatus: ok.errorsas a string/number, null/non-object entries, missing or non-stringmessage— throwsExportError("unparseable", 6, …)naming the shape. No bare TypeError escapesrun(); non-string messages are not coerced (drift surfaces, it is not guessed — same idiom as supabase-export: billedFromAddons silently zeroes unpriced selected addons (and accepts negative amounts) — a paid addon reports as measured $0 #2492).errors, and valid arrays, behave exactly as today.cloudflare-quota-export.test.mjs): string,[null],[{message: number}], single-object auth (→ token-scope/3), plus valid and auth-array controls; existing suite green.parseGraphqlUsage, billing-probe classification,main()'s generic wrapping.dezren39 commentedon Nov 1, 2026
🤖 Agent note · picked up
Merge pull request #3463 from developing-today/queue/5394-2608-gql-er…
dezren39 commentedon Nov 1, 2026
🤖 Agent note · done
node --test cloudflare-quota-export.test.mjs18/18 pass