Skip to content

Pre-prod hardening 4/4: GraphQL API hardening + ops docs#670

Open
aditya1702 wants to merge 5 commits into
hardening/pr3-ingestionfrom
hardening/pr4-graphql-docs
Open

Pre-prod hardening 4/4: GraphQL API hardening + ops docs#670
aditya1702 wants to merge 5 commits into
hardening/pr3-ingestionfrom
hardening/pr4-graphql-docs

Conversation

@aditya1702

@aditya1702 aditya1702 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

Pre-production hardening 4/4 (stacked on #669). GraphQL API hardening plus the production operations reference.

Changes

  • Dataloader batches grouped by query shape: loaders applied keys[0]'s columns/limit/cursor to the whole batch, so two aliased fields with different arguments in one query could silently get each other's data (or panic on a nil limit). Batches are now grouped by (columns, limit, cursor, sort) shape; nil/non-positive limits fail closed. Regression tests cover the aliased-query cases.
  • Complexity regression test: locks the freighter full-detail query under the production GRAPHQL_COMPLEXITY_LIMIT=6000 so future schema/complexity changes can't silently break the primary consumer.
  • API hardening: per-request timeouts, query depth limit, error masking (internal errors no longer leak SQL/driver text to clients), root-query time bounds, introspection env-gating, and UInt32 scalar accepting all numeric wire types.
  • 1ms dataloader collection window (up from the 16µs default) with per-loader batching metrics — cuts per-request query count for the freighter full-detail shape.
  • Docs: production operations reference (docs/operations.md) and GraphQL README synced to the actual schema (documented-but-nonexistent mutations removed, all current root queries documented). Two cursor-CAS SQL casts widened ::integer::bigint.

🤖 Generated with Claude Code

The generic one-to-many/one-to-one dataloader helpers applied keys[0]'s
Columns/Limit/SortOrder/Cursor to every key in a batch window, so aliased
fields with differing sub-selections received silently under-fetched
columns, nested pages were clamped to a hidden cap of 10 (with cursors
dropped and hasNextPage wrong) whenever >=2 parents batched together, and
a nil limit would panic the whole batch.

Batches are now grouped by QueryShape before fetching (mirroring the
account-scoped loader), each group fetches with its own parameters, and
cursored groups with >1 key fall back to per-key fetches. The hidden
MaxOperationsPerBatch/MaxStateChangesPerBatch=10 caps are removed in
favor of a documented maxNestedPageLimit=100 enforced at the resolvers
(same reject policy as the account connections), and loaders fail closed
on missing/non-positive limits.
Two regression tests protect the production constraint that freighter's
account-detail queries stay under GRAPHQL_COMPLEXITY_LIMIT=6000: the two
heaviest real client query shapes (balances first:100 = 3901, transactions
first:100 with embedded operations/stateChanges = 5601) are computed
against the production complexity config and asserted under the limit, and
a guard asserts AccountTransactionEdge.operations/stateChanges carry no
complexity multiplier — with a break-detection case proving that adding
the naive x50 multiplier pushes the transactions query to 196501.
@aditya1702
aditya1702 force-pushed the hardening/pr3-ingestion branch from f60a3a1 to 50ca382 Compare July 16, 2026 20:41
@aditya1702
aditya1702 force-pushed the hardening/pr4-graphql-docs branch from 796bc80 to 4fdc107 Compare July 16, 2026 20:41
…root time bounds

Serving hardening: WriteTimeout/IdleTimeout on the HTTP server plus a 30s
per-request context deadline (previously a slow query or client pinned a
DB connection indefinitely against a small pool); a query-depth limit of
15 (complexity does not bound depth — first:1 chains cost ~1 per level);
introspection now gated behind --graphql-introspection-enabled (default
off); Prometheus operation labels derive from schema root field names
instead of the client-controlled operationName (bounded cardinality).

Error handling: the presenter masks any error without a known client-safe
code as a generic internal error (raw SQL/driver text no longer reaches
clients) and logs the original; pagination and cursor parse failures are
coded BAD_USER_INPUT so they survive the masking; parse/validation-phase
errors no longer panic the presenter (operation context is absent there).

API contract: the three root connections cap pages at 100 (rejecting
oversized and int32-overflow first/last values before any arithmetic) and
accept since/until time bounds, defaulting to the last 7 days when
unspecified — root pages now carry a chunk-excluding time predicate, and
cursors page within the caller's window. UInt32 input parsing accepts all
JSON numeric encodings with range checks.
…metrics

A GraphQL request's loader keys arrive in a near-instant burst (sibling
resolvers fan out concurrently), but a default 50-parent page sits under
the 100-key batch capacity and waited the full 5ms window at every nested
loader level — a serialized ~5ms-per-level latency floor that often
exceeded the batch SQL itself. The window drops to 1ms via shared
constants (measured on a nested transactions->operations->stateChanges
shape: 13.3ms -> 3.3ms mean), and batching efficiency becomes observable:
every loader records batch size and fetch duration histograms so a
shorter window producing partial batches — or a future N+1 regression —
shows up on a dashboard instead of in a profile. A coalescing unit test
pins that concurrent same-shape loads still collapse into one fetch.
@aditya1702
aditya1702 force-pushed the hardening/pr4-graphql-docs branch from 4fdc107 to 39539f9 Compare July 16, 2026 21:13
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.

1 participant