Back to Knowledge Base

GraphQL and API Management — Deep Dive

Related: AI gateway — the same edge, for models · LLM gateway in this stack

01. Two Layers One Edge

ELI5 — the plain-language version

Imagine a building with a security desk at the main entrance that checks everyone’s ID, limits how many times they can come in, and watches for troublemakers—but once inside, each room has its own rulebook about which items you can actually touch. That’s the two-layer edge for APIs: one system guards the front door, and a separate system governs what happens inside each room. This setup is for managing APIs at both the transport edge (who gets in, how often) and the graph level (what exactly they’re allowed to ask for).

The front-door system—an API management platform—handles authentication (checking badges), consumer credentials (partner IDs), quotas and rate limits (how many visits per hour), spike arrest (slowing a sudden rush), web application firewall rules, and IP policies (blocking certain addresses). It works across every API the company exposes, whether REST, SOAP, or GraphQL. But to that front desk, GraphQL traffic looks like a single opaque POST request—like a visitor who enters carrying a sealed box. The front desk cannot see inside the box, so it cannot rate-limit an expensive query differently from a cheap one, cannot tell which fields a consumer uses, and cannot detect if the schema changed in a breaking way.

The deeper layer—the GraphQL platform—adds the room-specific rules: per-field authorization declared as schema directives, depth and complexity limits so a single query can’t ask for too much, and persisted-operation allow-lists that only run pre-approved requests. This layer also includes schema registry checks and per-field usage reporting. Without it, the front door would let in a wildly expensive query that asks for entire database dumps, and the system would crash or slow to a crawl—because the security desk had no way to know the box was full of lead weights.

API management platforms and GraphQL platforms are two complementary governance layers, not competitors. The management product governs the transport edge of the entire API estate. It includes authentication, consumer credentials, quotas, and rate limits. It also applies spike arrest, web application firewall rules, and IP policies.

But to a generic API management tool, GraphQL is one opaque post endpoint. These endpoint-level tools cannot rate-limit an expensive query differently from a cheap one. They cannot tell which fields a consumer uses. They cannot detect a breaking schema change. So treating the graph as just another API yields exactly one governed route and zero graph governance.

The GraphQL platform completes the picture. It includes a router and schema registry. It offers persisted operation allow-lists, which restore cacheability and shrink the attack surface. It applies depth and complexity limits. It provides per-field telemetry and usage-aware breaking-change gates. Field-level authorization inside the graph is declared as schema directives and enforced at execution.

The split exists because a gateway sees only the transport endpoint. The management layer controls the front door. The router and registry own the graph. Buying one and assuming it covers the other is the failure mode to warn against.

Configuring a resolver to map a GraphQL field to a backend HTTP API

python
<http-data-source>
  <http-request>
    <set-method>GET</set-method>
    <set-url>https://myapi.contoso.com/api/users</set-url>
  </http-request>
</http-data-source>
System design — mechanism, invariant, trade-off

The subsystem described in "Two Layers One Edge" operates as a sequential two-stage governance pipeline. First, the API management platform handles transport—authentication and edge security, consumer management with credentials and quotas, cross-API policy (logging, throttling, header manipulation), and consumer-level analytics. It validates the token at the front door, enforces spike arrest and Web Application Firewall rules, and forwards claims to the inner layer. Second, the GraphQL platform (router plus schema registry) takes over: it applies persisted-operation allow-lists to restrict execution to registered documents, enforces depth/complexity/cost limits per query shape, runs schema registry checks and usage reporting per field per client, and executes field-level authorization declared as schema directives. If a request fails at the first layer (e.g., invalid token), it is rejected before reaching the graph; if it passes but violates a graph-layer constraint (e.g., unregistered operation), it is rejected at the router.

The design preserves the invariant that transport governance and graph governance are complementary, non-overlapping layers: the management product owns the endpoint-level security and consumer onboarding, while the GraphQL platform owns schema-aware, per-query control. This separation ensures that each layer handles only what it can express—the gateway cannot see or govern the shape of GraphQL queries, and the graph router cannot issue API keys or enforce rate limits per consumer. The failure mode the designers explicitly warn against is "buying one and assuming it covers the other." The alternative rejected is attempting to implement both responsibilities in a single product, which would require the API management tool to understand GraphQL semantics (field selection, query complexity, schema evolution) or the GraphQL platform to manage consumer credentials and transport policies. The cost avoided by this choice is the inability to enforce per-query cost and schema-aware governance—an endpoint-level tool "cannot rate-limit an expensive query differently from a cheap one, cannot tell which fields a consumer uses, cannot detect a breaking schema change." By keeping layers separate, each can do its job without needing to replicate the other's domain.

A concrete failure mode emerges when an enterprise deploys only the API management layer and neglects the GraphQL platform. An operator would see a single expensive query—say one requesting thousands of nested fields—overwhelming the backend. The API management console logs only one POST to /graphql, shows a high total call count and latency, but provides no per-field or per-operation breakdown. There is no alert for "breaking schema change" because none exists; the operator might instead observe a sudden surge of client errors after a schema modification, with no trace linking the errors to a field or consumer. The signal is a vague latency spike and increased error rate, with no way to distinguish an abusive query from a legitimate one. The only recourse is to manually inspect GraphQL logs outside the management system, because "endpoint-level tools therefore cannot rate-limit an expensive query differently from a cheap one."

Failure modes — what breaks, what catches it

Expensive query abuse — no query‑level cost limit

  • Trigger – A consumer sends a deeply nested or highly complex GraphQL query that demands disproportionate backend resources.
  • Guard – None. The source explicitly states that endpoint‑level tools “cannot rate‑limit an expensive query differently from a cheap one”. API Management’s spike arrest and rate‑limits operate on call count, not query shape.
  • Posture – Fail‑soft. The query is executed, degrading backend performance and potentially overwhelming the data source.
  • Operator signal – No direct metric; the only observable symptom is elevated backend latency or error rates that cannot be attributed to any single consumer or operation.
  • Recovery – Manual. The operator must deploy a graph‑aware router (referenced as “complexity limits” in the companion text) or migrate to a synthetic GraphQL API with custom resolvers that enforce cost thresholds.

Breaking schema change — consumer queries silently fail

  • Trigger – The backend GraphQL schema evolves (e.g., a field is removed or renamed) without a coordinated roll‑out.
  • Guard – None. The source says APIM “cannot detect a breaking schema change”. The schema imported into the management plane is static until re‑imported.
  • Posture – Fail‑soft. Queries that reference the removed field return errors, but other queries continue to work.
  • Operator signal – Consumer‑side error reports (e.g., Cannot query field); no alert is raised in API Management.
  • Recovery – Manual. The operator must re‑import the updated schema and, if using a pass‑through API, adjust backend or rollback the schema change. A graph‑aware registry would provide “usage‑aware breaking‑change gates”.

Missing field‑level usage visibility — blind optimization

  • Trigger – Operations need to know which fields are accessed by which consumers to plan deprecations or optimizations.
  • Guard – None. The source states APIM “cannot tell which fields a consumer uses”. The portal provides “consumer‑level analytics” only at the transport level.
  • Posture – Fail‑soft. The system continues to serve queries, but deprecation decisions are made without per‑field telemetry.
  • Operator signal – Silent absence; no field usage report exists in API Management.
  • Recovery – Manual. The operator must instrument a separate graph‑aware layer (schema registry) that supplies “per‑field, per‑client telemetry”.

Inefficient caching due to opaque POST endpoint

  • Trigger – Repeated identical GraphQL queries are sent by different clients, all going to the same backend.
  • Guard – None. The source explains that APIM “cannot cache by URL” because all queries arrive as one POST with varying bodies. REST‑style caching policies (e.g., cache-store, cache-lookup) are ineffective.
  • Posture – Fail‑soft. Every query still reaches the backend; no cached response is served, increasing latency and load.
  • Operator signal – High backend response times and repeated backend calls for identical data; no cache‑hit metric for GraphQL.
  • Recovery – Manual. The operator must adopt persisted operations over GET (referenced as “persisted queries over GET”) to restore cacheability, typically via a graph router.

Field‑level authorization bypass — security exposure

  • Trigger – A consumer requests a field they are not allowed to read, but the request reaches the backend because API Management enforces only transport‑level auth (e.g., JWT validation).
  • Guard – None at the API Management layer. The source says “field‑level authorization … declared as schema directives, enforced at execution” belongs to the graph platform, not to APIM.
  • Posture – Fail‑open. The query proceeds and the unauthorized field may be returned unless the backend itself enforces per‑field access.
  • Operator signal – Possibly none; a security audit or consumer misuse would reveal the gap.
  • Recovery – Manual. The operator must implement field‑level authorization inside a graph‑aware router or embed custom resolver policies that check claims against each field.
STUDY AIDSevidence-backed memory techniques
Recall check

In Two Layers One Edge, what triggers Expensive query abuse — no query‑level cost limit — and how is it caught?

Show answer

A consumer sends a deeply nested or highly complex GraphQL query that demands disproportionate backend resources.

Recall check

In Two Layers One Edge, what triggers Breaking schema change — consumer queries silently fail — and how is it caught?

Show answer

The backend GraphQL schema evolves (e.g., a field is removed or renamed) without a coordinated roll‑out.

02. What The Gateway Owns

ELI5 — the plain-language version

Imagine a doorman at a club: this subsystem is exactly that — the front door that checks who you are and lets you in only if you have the right pass. Its purpose is to keep uninvited guests out and to track how many times each guest comes in.

The doorman first checks your ID — that's token validation, using policies like OAuth2 and VerifyAPIKey to confirm your credentials before you even step past the threshold. If you're a known partner, you get a subscription key, like a membership card. The doorman also enforces quotas — you can only come in five times an hour — and rate limits, so no single guest hogs the entrance. A developer portal lets partners sign up, browse the rules, and get their own key. The same doorman applies the same set of rules to all types of guests — whether they come for REST, SOAP, or GraphQL — logging every visit, throttling if the line gets too long, and reporting analytics that tell you exactly which partner is causing a commotion.

The tricky part, however, is that the doorman only checks your ID and your entry quota — he doesn't know which rooms you're allowed to enter once inside. That fine-grained authorization — per-field permissions declared as schema directives — is handled by a separate bouncer inside the graph. Without the doorman, anyone could walk straight in with no check, no limit, and no record — leaving you blind to who is overloading your servers or misusing your data.

The API management layer acts as the front door for your APIs. At this entrance, it validates tokens like OpenID Connect and JSON Web Tokens before a request ever reaches the graph. It issues and manages per-consumer subscription keys, sets quotas, and enforces rate limits. A developer portal lets partners self-serve onboarding and browse documentation.

This same layer applies one set of policies for logging, throttling, and header handling across all API types—REST, SOAP, and GraphQL alike. It also reports consumer-level analytics, so you know which partner is affected by any issue.

A management product can add a GraphQL API in two ways. One is a pass-through to an existing GraphQL endpoint. The other is a synthetic API whose fields resolve to other back ends. The schema can be retrieved from a backend endpoint or uploaded directly.

This front door handles coarse security and transport policy. Fine-grained field authorization then lives inside the graph itself. The two layers work together, not in competition.

A synthetic GraphQL API field resolver uses an HTTP data source policy to map to a backend REST endpoint.

python
<http-data-source>
  <http-request>
    <set-method>GET</set-method>
    <set-url>https://myapi.contoso.com/api/users</set-url>
  </http-request>
</http-data-source>
System design — mechanism, invariant, trade-off

The gateway begins by validating the caller’s credentials at the transport edge. A VerifyAPIKey or OAuth2 policy runs as the first step, checking subscription keys or JSON Web Tokens (JWT). If validation passes, the gateway forwards the token claims (for example, consumer identity and scopes) and then applies transport‑level policies—Quota for rate limits, spike arrest, logging, and header manipulation—uniformly across all API types. On failure, the gateway raises a fault; the continueOnError attribute on the policy determines whether execution halts or continues. If the fault is not handled, a standard error response (e.g., 401 for invalid API key) is returned immediately, preventing the request from reaching the graph layer. This ordered mechanism ensures that only authenticated, rate‑limited traffic ever touches the GraphQL router.

The design preserves an invariant that the API management layer never interprets the GraphQL payload. As the source states, “to a generic API management product GraphQL is one opaque POST endpoint.” The gateway treats every GraphQL request as a single transport transaction; it does not parse fields, measure query depth, or detect breaking schema changes. That responsibility belongs entirely to the graph platform (the router and schema registry). This invariant guarantees that transport governance—who, how often, with what credentials—remains entirely independent from graph governance—what fields, how expensive, how the contract evolves.

The key trade‑off is that the gateway rejects the temptation to embed graph‑aware logic. An obvious alternative would be to put schema validation, complexity limits, and field‑level authorization inside the API management layer itself. The source explicitly warns against this: “The failure mode to warn against is buying one and assuming it covers the other.” Keeping the gateway generic avoids the cost of reimplementing graph‑specific rules that the router already handles, and prevents duplicating the schema registry’s per‑field telemetry and breaking‑change gates. This separation also allows each team to evolve its layer independently—the API management team can upgrade transport policies without touching the graph, and the graph team can tighten field‑level directives without changing gateway configuration.

A concrete failure mode is an expired subscription key that passes no security check. The operator would see a 401 Unauthorized response with a fault variable such as fault.name set to InvalidApiKey (or InvalidAccessToken for OIDC/JWT). In the Apigee UI, the fault rule’s trace log would show the VerifyAPIKey policy raising an error; the continueOnError flag—if left at its default false—causes the flow to abort immediately, and the gateway logs this as a transport‑level rejection. No request reaches the GraphQL router, so no graph‑level logging occurs—the operator knows instantly which consumer failed authentication.

Failure modes — what breaks, what catches it

Failure 1: GraphQL payload exceeds configured maximum depth

  • Trigger — A request contains a query whose depth (when represented as a tree) surpasses the integer assigned to the <MaxDepth> element.
  • Guard — The <MaxDepth> element (default value 10 in the default policy) blocks the query before the backend is invoked.
  • Posture — fail-closed: the policy rejects the request and does not forward it to the GraphQL backend.
  • Operator signal — A fault rule is triggered because the policy enters an error state (the source states “Fault rules are triggered ONLY in an error state”). No exact log line is specified in the source.
  • Recovery — No automatic retry or fallback exists; the client must resubmit a query with a depth less than or equal to the <MaxDepth> value. Manual reconfiguration of the policy’s <MaxDepth> element is possible but the source does not describe automated recovery.

Failure 2: Number of fragments exceeds the configured maximum count

  • Trigger — The GraphQL payload contains more fragment definitions than the integer set in the <MaxCount> element (default 10).
  • Guard — The <MaxCount> element enforces the upper limit on fragments.
  • Posture — fail-closed: the request is not processed further.
  • Operator signal — A fault rule is triggered; the source gives no distinct error message beyond the generic fault‑rule mechanism.
  • Recovery — Client‑side correction is required (reduce the number of fragments). No retry logic is provided by the policy.

Failure 3: Payload size exceeds the optional maximum payload restriction

  • Trigger — The content length of the GraphQL payload (body or query parameter) is greater than the value assigned to <MaxPayloadSizeInBytes>.
  • Guard — The <MaxPayloadSizeInBytes> element (absent in the default policy; if omitted, no size restriction is enforced).
  • Posture — fail-closed: the request is blocked.
  • Operator signal — A fault rule fires because the policy encounters an error. The source does not provide a specific log line.
  • Recovery — The client must reduce the payload size; no automatic retry or fallback is defined.

Failure 4: GraphQL request does not conform to the uploaded schema

  • Trigger — The <Action> element is set to verify or parse_verify, and the GraphQL payload fails schema validation against the file referenced by the <ResourceURL> element.
  • Guard — The verify action (or the combined parse_verify action) checks the payload against the schema identified by <ResourceURL>.
  • Posture — fail-closed: the request is rejected and not forwarded.
  • Operator signal — A fault rule is triggered; the source mentions no detailed error field beyond the generic error state.
  • Recovery — Manual correction of the GraphQL request or updating the schema file is required. No automatic retry is present.

Failure 5: Policy execution error with continueOnError set to false

  • Trigger — Any runtime error within the <GraphQL> policy (e.g., malformed payload, missing required elements) while the attribute continueOnError="false" is set.
  • Guard — The continueOnError attribute (default is false per the syntax) determines whether the policy halts the flow or continues. When false, the error stops processing.
  • Posture — fail-hard: the entire proxy execution terminates in an error state.
  • Operator signal — Fault rules are triggered; the source notes “Fault rules are triggered ONLY in an error state (about continueOnError)” and “Handling faults within the current flow”.
  • Recovery — No automatic re‑attempt; a human operator must inspect the fault rule logic and the payload to resolve the underlying cause. The source does not specify retry counts or backoff.
STUDY AIDSevidence-backed memory techniques
Recall check

In What The Gateway Owns, what triggers GraphQL payload exceeds configured maximum depth — and how is it caught?

Show answer

A request contains a query whose depth (when represented as a tree) surpasses the integer assigned to the `<MaxDepth>` element.

Recall check

In What The Gateway Owns, what triggers Number of fragments exceeds the configured maximum count — and how is it caught?

Show answer

The GraphQL payload contains more fragment definitions than the integer set in the `<MaxCount>` element (default `10`).

03. What The Gateway Cannot See

ELI5 — the plain-language version

Imagine a security guard at a building’s front door who checks badges and counts how many people enter, but has no idea what each person does inside—someone could be carrying a thousand heavy boxes that will crush the floor, and the guard would never know. This is the gateway: it controls who gets in and how often, but it cannot tell if one GraphQL query is far more expensive than another. The graph layer itself must decide that, because a single deeply nested query can ask for millions of items while appearing as one innocent request.

That graph layer works step‑by‑step: it first limits how deep a query can go (depth limiting), so you cannot keep asking “friends of friends” forever. It also puts a cap on how many different top‑level fields or aliases appear in one operation (breadth limiting), stopping an attacker from sending, say, a hundred aliased “friends” fields. And it restricts how many separate queries can be batched in one request (batch limiting), so you cannot sneak in fifty different hero queries at once. To assign real cost, the graph can use a “node_quantifier” strategy that counts every actual node returned—if a field returns 100 items, each of those becomes a multiplier for the sub‑fields inside it, letting the server reject a query that would visit too many nodes.

The trickiest part is why a default strategy that simply counts nesting (cost=1 per operation) is not enough. Without quantifier decorations, a client could request allPeople(first:100) and then vehicleConnection(first:10) inside each person, producing 100 + 10×100 = 1100 items, yet the default strategy would charge only 1. The node_quantifier strategy fixes this by requiring those first: arguments to be decorated in the schema—then the cost multiplies correctly. Without this subsystem, an attacker could craft a query with moderate depth but massive fan‑out (e.g., 100 friends, each with 100 posts, each with 100 comments) that the gateway would never flag; the server would grind to a halt trying to resolve a billion nodes, and users would feel the slowdown or crash directly.

A gateway that only sees endpoints cannot fully protect a GraphQL API. It can control who accesses it and how often. But it cannot tell if one query is far more expensive than another. The graph layer itself must decide that. Paginated fields help limit data per field. Yet without graph-aware controls, an attacker can send deeply nested queries. Cyclic queries are also possible. They place excessive load on the server. Batch limiting and breadth limiting are needed to stop many operations in one request. The gateway cannot do that by itself. It also cannot report which fields a consumer actually uses. That means you cannot detect breaking schema changes from usage. Additionally, introspection left open in production gives away the schema. An attacker can probe it to find weaknesses. Trusted documents help but only work for first-party clients. So the attack surface includes query shape attacks. Depth limiting and batch limiting must happen inside the graph. The gateway handles transport and credentials. The graph handles cost and authorization. The management layer can rate-limit per registered operation. That requires persisted documents though. For public APIs, those are not possible. So a gateway alone leaves many doors open. The two layers each have their own job. One manages who comes in, the other manages what they can do.

Cost calculation for a paginated GraphQL query showing how query shape determines cost.

python

# Configuration: mul_arguments, mul_constant, etc.
root_cost = 1
vehicle_conn_cost = 100 * 42  # 100 calls, each cost 42
film_conn_cost = 10 * 100     # 10 * 100 calls
character_conn_cost = 5 * 10 * 100  # 5 * 10 * 100 calls
total_cost = root_cost + vehicle_conn_cost + film_conn_cost + character_conn_cost
# total_cost = 10201
System design — mechanism, invariant, trade-off

The system is built as a two-layer defense. First, the API management layer (the gateway) enforces transport security: it validates credentials, meters requests per consumer, and applies coarse rate limits. Only after that handshake does the query reach the graph layer, which owns schema‑aware governance. The ordered mechanism begins with the gateway’s token validation and forwarding of claims; then the graph router consults its registry for field‑level authorization directives, applies a cost‑estimation strategy (selected via config.cost_strategy, either default or node_quantifier), and evaluates depth limits and paginated field constraints. If the query exceeds the declared cost or depth, execution is halted and an error is returned before any data fetching occurs. On failure at the graph layer, the operation is rejected; the gateway never sees the internal reason because it has already passed its own checks.

The invariant preserved by this design is that no query can exhaust server resources beyond what the schema’s cost decorations allow. The graph layer guarantees that every operation falls within the boundaries defined by type_path, mul_arguments, mul_constant, add_arguments, and add_constant in the cost decoration schema, and by the maximum depth limit. This “schema‑aware governance” ensures that an expensive nested or cyclic query cannot slip through even though the gateway sees only a single endpoint call. The gateway’s invariant—coarse per‑consumer rate limiting—operates independently; the graph layer’s invariant is strictly finer‑grained and cannot be expressed at the transport level.

The key trade‑off is separating coarse transport policy from fine‑grained graph policy, rejecting the alternative of placing all controls in the gateway. If the gateway alone attempted to protect the graph, it would have to guess query cost from endpoint metadata, which is impossible for deeply nested or cyclic queries that appear as a single request. That alternative would force either rejecting all moderately deep queries (over‑restriction) or allowing malicious ones (under‑protection). By delegating cost decisions to the graph layer, the design avoids the cost of misestimating query expense; it also avoids bloating the gateway with schema introspection logic, which would couple transport and application concerns. The cost of this separation is that two independent systems must be configured consistently, but that is offset by the flexibility to use any gateway (including Kong’s branded APIM) while retaining graph‑native controls.

A concrete failure mode occurs when an attacker sends a deeply nested query with many list‑type fields, for example a query { hero { name friends { name friends { name friends { name … } } } } } that repeatedly nests friends. The gateway sees one request and passes it. Without depth limiting in the graph layer, the server attempts to resolve an exponential number of nodes. The operator would observe a spike in response latency and a rising error rate (likely timeouts or out‑of‑memory errors) in the graph service’s metrics, while the gateway’s dashboard shows normal request counts. The signal is a mismatch: gateway metrics look healthy, but graph‑service CPU and memory are saturated and errors climb. The remedy is to enforce a strict maximum depth via the graph router’s configuration, which the context explicitly names as “depth limiting” and contrasts with “paginated fields” as two distinct demand‑control mechanisms.

Failure modes — what breaks, what catches it

Deeply nested list fields causing exponential data load

  • Trigger — A client sends a query with repeated nesting of list fields, e.g., hero { friends { name friends { name friends { ... } } } }, that can exponentially increase the number of resolved nodes.
  • Guard — The source does not specify an exact guard identifier; the described guard is depth limiting (a configuration option to set a maximum depth for a single operation, with a separate smaller limit for list nesting).
  • Posture — Fail-hard (aborts execution before resolution) — the source explains that an error is returned to the client if the request exceeds the configured maximum depth.
  • Operator signal — The client receives a validation error indicating the query exceeds the allowed depth; the operator sees no additional server‑side log unless the implementation explicitly logs depth‑limit violations.
  • Recovery — The client must rewrite the query to respect the depth limit and resend; no automatic retry is provided by the server.

Batch of many operations in a single request

  • Trigger — A client sends a GraphQL document containing dozens or hundreds of batched query operations (e.g., query NewHopeHero { … } query EmpireHero { … } … query JediHero { … }) to bypass per‑request rate limits.
  • Guard — The source does not specify an exact guard identifier; the described guard is an upper limit on the total number of queries allowed in a single batch.
  • Posture — Fail-hard (rejects the entire batch before execution) — the server returns a validation error for the batch if the limit is exceeded.
  • Operator signal — The client receives a batch‑limit exceeded error; the server may log a warning with the number of queries attempted.
  • Recovery — The client must split the operations into separate requests (or fewer batches) and resend; no automatic retry.

Breadth attack using many field aliases

  • Trigger — A client includes a large number of top‑level field aliases, e.g., friends1: friends(limit:1) { name } friends2: friends(limit:2) { name } ... friends100: friends(limit:100) { name }, forcing the server to resolve the same expensive field many times.
  • Guard — The source does not specify an exact guard identifier; the described guard is a limit on the number of top‑level fields and field aliases included in a single operation.
  • Posture — Fail-hard (rejects the query before execution) — an error is returned when the alias count exceeds the configured limit.
  • Operator signal — The client receives a validation error with a message such as “too many aliases”; the server may report the count in its logs.
  • Recovery — The client must reduce the number of aliases or batch the requests differently; no automatic retry.

Expensive field resolution bypassing endpoint‑level controls

  • Trigger — An attacker sends a query that resolves a computationally expensive field (e.g., allPeople(first:100) { … vehicleConnection(first:10) { … filmConnection(first:5) { … } } }) that the gateway cannot differentiate from a cheap query.
  • Guard — The GraphQL Rate Limiting Advanced plugin with config.cost_strategy set to node_quantifier. This strategy assigns costs based on quantifier arguments (first, limit) and multiplies them across nesting levels, as shown in the cost‑decoration schema using parameters mul_arguments, mul_constant, add_arguments, add_constant.
  • Posture — Fail-closed (refuses the write/query if the computed cost exceeds the rate‑limit window) — the plugin returns a rate‑limit error and does not execute the operation.
  • Operator signal — The client receives a HTTP 429 or a GraphQL error indicating the query cost exceeds the allowed budget; the plugin may emit a metric tracking the computed cost.
  • Recovery — The client must wait for the rate‑limit window to reset (or reduce the query’s cost) before retrying; no automatic retry is provided.

Arbitrary query injection when trusted documents are not used

  • Trigger — A client sends a GraphQL document that was never reviewed or approved, potentially containing malicious field accesses or deep nesting that the gateway cannot validate.
  • Guard — The source does not specify an exact guard identifier; the described guard is trusted documents (an allowlist of known, hashed GraphQL documents that the server will execute; unknown documents are rejected).
  • Posture — Fail-closed (refuses to execute any document not in the allowlist) — the server returns an error for unknown document IDs.
  • Operator signal — The client receives an error like “document not found” or “unknown operation”; the server may log the hash of the rejected document.
  • Recovery — The client must submit the new document through the development allowlist process; once approved, the document ID can be used. No automatic retry occurs for rejected documents.
STUDY AIDSevidence-backed memory techniques
Recall check

In What The Gateway Cannot See, what triggers Deeply nested list fields causing exponential data load — and how is it caught?

Show answer

A client sends a query with repeated nesting of list fields, e.g., `hero { friends { name friends { name friends { ...

Recall check

In What The Gateway Cannot See, what triggers Batch of many operations in a single request — and how is it caught?

Show answer

A client sends a GraphQL document containing dozens or hundreds of batched query operations (e.g.

04. Cost Not Call Count

ELI5 — the plain-language version

Imagine a restaurant where one customer can place a single order that asks for a hundred different dishes, each dish containing dozens of ingredients, and each ingredient requiring multiple preparation steps. This subsystem is a billing system that charges based on the total workload of that order, not just how many times the customer called the waiter—it prevents a single request from secretly overwhelming the kitchen.

The router reads the query like a recipe card, then walks through every field to calculate a cost. Each field that returns a list, like allPeople(first:100), gets its base cost multiplied by the number of items it returns (controlled by a list_size or a slicingArguments parameter). If that list is nested inside another list—for example, each person has a vehicleConnection(first:10)—the cost multiplies again: 100 people × 10 vehicles = 1,000. Deeper nests keep multiplying, so a query with four nested lists could have a cost of tens of thousands. The router also enforces per-subgraph limits: for the products subgraph, you can set a separate list_size of 20 and a max cost of whatever the configuration allows, so no single subgraph can be overloaded by one order.

The most subtle rule is that if the same subgraph is fetched multiple times during a single query—for example, through entity lookups or nested conditional branches—all those individual costs are summed together, and the subgraph’s total limit is enforced against that combined number. This matters because a query might look up a user, then look up more user details, and then look up their friends—all hitting the same users subgraph—and the router adds up every call. Without this subsystem, a single innocent-looking query could silently request billions of database rows, causing the server to run out of memory and return an error like "internal server error" or timeout—a failure a beginner would feel as an unresponsive website or a blank screen.

GraphQL cost is based on the shape of a query, not just how many times it is called. That matters because a single call can ask for huge amounts of data.

The router checks each operation against a cost model before running it. That model comes from a specification called the cost directive, which lets you assign a cost to each field. For example, a field that returns a list of people has its cost multiplied by the number of people returned. If that field is nested inside another list, the cost multiplies again. So the total cost depends on how deeply the query nests lists and how many items each list asks for.

There are two ways to estimate cost. The default strategy gives every operation a cost of one. A more precise strategy, called node quantifier, uses the quantifier arguments on connections to calculate cost. The cost is computed before execution — it is a static estimate. After the query runs, the actual cost can be measured. The system records both numbers as histograms, so you can see the difference between estimated and actual cost. That helps you set the right ceiling.

Before enforcing a limit, you can run the control in a measure only mode. That way you calibrate the maximum cost without rejecting any queries.

Alongside this cost model, there are simpler blunt limits. You can limit the maximum depth of a query, the number of aliases, the number of root fields, or the total number of queries in a batch. These prevent cycles and batch attacks.

Finally, gateway plugins let you assign a cost to each field and then rate limit a consumer by the total accumulated cost over a time window. If a user's query costs too much, the plugin rejects it.

GraphQL query showing cost multiplication based on nested list sizes.

python
query {
  allPeople(first:100) { # 1
    people {
      name
      vehicleConnection(first:10) { # 100
        vehicles {
          name
          filmConnection(first:5) { # 10 * 100
            films{
              title
              characterConnection(first:50) { # 5 * 10 * 100
                characters {
                  name
                }
              }
            }
          }
        }
      }
    }
  }
} # total cost: 1 + 100 + 10 * 100 + 5 * 10 * 100 = 6101
System design — mechanism, invariant, trade-off

The demand‑control subsystem in Apollo Router protects supergraphs from overly complex GraphQL operations by assigning a cost to each operation based on the shape of the query—not merely how many times it is called. The ordered mechanism works as follows: when a request arrives, the router first evaluates the operation against a cost model derived from the @cost directive. For each field, the router uses that directive’s weight argument to compute a base cost, then multiplies it by any quantifier arguments (e.g., first, list_size). Nested list fields cause multiplicative cost increases, so a single call that requests deeply nested paginated data can accumulate a very high cost. The router sums the costs of all sub‑requests planned for each subgraph, and if the total exceeds the configured max for that subgraph (defined under subgraph.all or per‑subgraph overrides like subgraphs.products), it rejects the operation. In measure mode, the rejection is only recorded for observation; in enforcement mode, the operation is actively blocked. Telemetry exposes the outcome via the graphql.operation.name attribute and the "COST_ESTIMATED_TOO_EXPENSIVE" cost result, visible in histograms like cost_delta and cost_actual.

The critical invariant the design preserves is a demand‑control guarantee: the total estimated cost per subgraph, across all fetches in the query plan, must stay under the configured maximum. This is not a call‑count limit (e.g., “100 queries per minute”) but a cost limit that reflects the real work a single operation can impose. Because the same subgraph may be fetched multiple times through entity lookups or nested branches, costs are summed and checked together, ensuring that no subgraph is overloaded by one request, even if that request is a single “call.” The system enforces this invariant by rejecting any operation whose estimated cost exceeds the per‑subgraph threshold.

The key trade‑off is cost estimation over simple call counting. The obvious alternative—rate limiting by raw call count—is cheap to implement but oblivious to a query’s complexity. A single call with deeply nested lists (like the example showing characterConnection called 5 * 10 * 100 times) could easily produce thousands of sub‑requests, overwhelming a subgraph. A call‑count limiter would see only “one call” and allow it. The cost‑estimation approach avoids that failure by rejecting the operation before it executes. The price is computational overhead: every incoming query must be introspected, the @cost weights applied, and the nested multiplication computed, all before any data is fetched. However, this up‑front analysis prevents much larger downstream costs on subgraphs, making the trade‑off worthwhile for high‑volume or high‑complexity graphs.

A concrete failure mode occurs when an operation’s estimated cost exceeds the configured subgraph maximum. For example, if subgraph.all.max is set to 800 and a query against the users subgraph (which has a lower override of max: 200) is estimated at 862 (as in the vehicleConnection example), the router computes the cumulative sum and finds it above the limit. The operator would see the exact rejection signal in the telemetry: the histogram entry for cost_delta shows a bucket at le="1000" where the count is less than total operations, and the cost result attribute reads "COST_ESTIMATED_TOO_EXPENSIVE". Additionally, the graphql.operation.name attribute is attached to the span, allowing the operator to identify which operation was rejected and why. This concrete indicator—a metric label and a span attribute—gives the operator immediate visibility into the demand‑control decision.

Failure modes — what breaks, what catches it

Underestimated list size due to missing @listSize directive

  • Trigger — A field returns a large list (e.g., 1000 items) but the schema lacks a @listSize directive with assumedSize or slicingArguments. The router resorts to the global list_size default (e.g., 10), vastly underestimating the operation’s cost.
  • Guard — No guard exists. The @listSize directive is the intended mechanism, but it is absent. The configuration list_size is a static default, not a runtime guard.
  • Posture — Fail‑soft: the query executes because the estimated cost remains below any max limit. The server continues processing the operation, potentially causing resource exhaustion.
  • Operator signal — The router’s telemetry would show a large gap between estimated_cost (low) and actual_cost (high), but no alert is triggered unless custom monitoring is added.
  • Recovery — Manual: the schema owner must add the appropriate @listSize directive to the field, or an operator must increase the global list_size in the demand_control configuration. No automatic retry or fallback.

Subgraph cost limit exceeded

  • Trigger — The aggregate estimated cost for a specific subgraph (e.g., the products subgraph) surpasses its configured max (e.g., subgraph.subgraphs.products.max: 200).
  • Guard — The router’s per‑subgraph enforcement, defined by subgraph.all.max or subgraph.subgraphs.<name>.max. The router checks cumulative cost across all fetches to that subgraph.
  • Posture — Fail‑soft: the router skips calls to the over‑budget subgraph only and “composes the response as if that subgraph had returned null”. Other subgraphs still execute, and the overall operation continues.
  • Operator signal — Router logs may contain an entry like "demand_control subgraph limit exceeded"; telemetry spans include subgraph cost breakdowns.
  • Recovery — Automatic: no retry. The response returns null for the affected subgraph’s fields. The operator can adjust the subgraph’s capacity (raise max) or optimize the queries.

Incorrect @cost weight causing false rejection

  • Trigger — A schema field is annotated with @cost(weight: 5) even though its resolver is cheap. A legitimate query that selects that field now has an inflated estimated cost that may exceed the global demand_control.max.
  • Guard — None. The @cost directive applies literally; there is no validation that the weight matches actual resolver cost.
  • Posture — Fail‑closed: the router rejects the operation, returning an error like “query cost exceeds maximum” to the client. No data is returned.
  • Operator signal — The client sees a GraphQL error with a message indicating cost limit exceeded. Router logs record the estimated cost and the breach.
  • Recovery — Manual: the schema designer must correct the @cost weight. The client may retry with a different operation, but the rejection will persist until the schema is fixed.

Actual cost calculation mode by_response_shape hides expensive federation work

  • Trigger — The router is configured with actual_cost_mode: by_response_shape. A query triggers multiple entity lookups across subgraphs, but those lookups are not reflected in the final response shape. The reported actual cost is much lower than the true server work.
  • Guard — No guard exists. The mode is a static configuration choice; the router does not detect or correct the underestimation.
  • Posture — Fail‑soft: the query executes normally because neither estimated nor actual cost trigger a block. The skewed metric misleads operators about system load.
  • Operator signal — Telemetry shows cost_actual values that are lower than expectations. No error is raised.
  • Recovery — Manual: the operator must change the configuration to actual_cost_mode: by_subgraph (or to the default in newer Router versions). No automatic fallback or retry.
STUDY AIDSevidence-backed memory techniques
Recall check

In Cost Not Call Count, what triggers Underestimated list size due to missing @listSize directive — and how is it caught?

Show answer

A field returns a large list (e.g., 1000 items) but the schema lacks a `@listSize` directive with `assumedSize` or `slicingArguments`.

Recall check

In Cost Not Call Count, what triggers Subgraph cost limit exceeded — and how is it caught?

Show answer

The aggregate estimated cost for a specific subgraph (e.g.

05. Persisted Operations

ELI5 — the plain-language version

Think of a private party where a bouncer only admits guests whose names are on a pre-approved list. That’s what this system does for a GraphQL API: it turns the open door of queries into a secure guest list. The router accepts only operations that have been registered ahead of time, blocking any uninvited request.

Clients ship their operation documents—like a partygoer submitting their name—and the router stores them in a Persisted Query List (PQL). When a request arrives, the router checks if the operation string or its ID matches an entry. If safelisting is enabled, any unrecognized operation is rejected outright. A client can also include a client name, say via the apollographql-client-name header, so that only operations tied to that specific client are allowed. The router can even demand that requests use only the short ID instead of the full operation string—a setting controlled by the require_id sub-option.

The trickiest part is that the match must be exact: even a different field order or extra whitespace counts as a mismatch, because the router compares the raw document. If a client library automatically adds a __typename field but the registered operation doesn’t include it, the bouncer turns that guest away. Without this subsystem, any attacker could fire arbitrary queries against the API, exhausting resources or stealing data—your party would be wide open to anyone.

Persisted queries bridge client registration and edge execution. Clients register their operation documents ahead of time. The router then accepts only known operation identifiers. This turns an open query interface into an allow list. It shrinks the attack surface to only the operations that have been shipped. It also restores cacheability. A short identifier can travel in a GET request. A content delivery network can cache such a request.

There is a key distinction between two uses. A safelist is used for security. With safelisting enabled, the router rejects any unregistered operations. A different mode only reduces network bandwidth and latency. In that mode, clients send a hash of the operation string instead of the full operation. The router still accepts full operation strings. That mode does not provide safelisting.

Registering operations by publishing a manifest to a persisted query list links them to a specific graph variant. This lets the platform associate traffic with that variant. The variant can represent a deployment of the application. So each client version’s operations are registered together. The router knows which operations belong to that version.

Safelisting configuration for persisted queries, with operation IDs required and APQ disabled.

yaml
persisted_queries:
  enabled: true
  log_unknown: true
  safelist:
    enabled: true
    require_id: true
apq:
  enabled: false # APQ must be turned off
System design — mechanism, invariant, trade-off

The subsystem operates through a strictly ordered pipeline. First, clients register operation documents to a persisted query list (PQL) at build time, typically using the Rover CLI or a JSON manifest. At runtime, the GraphOS Router inspects each incoming request. If persisted_queries.safelist.enabled is true, the router rejects unregistered operations immediately. When persisted_queries.safelist.require_id is also true, the router additionally rejects any registered operation that arrives as a full operation string instead of its PQL-specified ID. If persisted_queries.log_unknown is true, every rejected operation is recorded in the router’s logs. Automatic persisted queries must be disabled (apq.enabled: false) for safelisting to function correctly, because APQ bypasses the PQL mechanism.

The invariant the design preserves is that only registered operations—and optionally only those supplied by ID—are allowed to execute. This is a strict allow‑list guarantee. The router enforces this by comparing the incoming operation or ID against the PQL and rejecting any match failure. The guarantee extends to client‑name associations: when an operation in the PQL carries a clientName, the request’s apollographql-client-name HTTP header must match that name, or the operation is denied. This turns an open query interface into a fully controlled execution plane, shrinking the attack surface to only the operations that have been intentionally shipped.

The key trade‑off is security versus operational flexibility. The obvious alternative—leaving the graph open to all operations—is explicitly rejected. By enforcing safelisting, the router prevents unregistered and potentially malicious queries from reaching the execution engine, but at the cost of requiring all client teams to pre‑register every operation and, at the highest security level, to send only IDs. The rejected alternative (open mode) would avoid this registration overhead but would expose the graph to arbitrary queries, increasing the risk of data leaks or denial‑of‑service. The cost avoided is the continuous threat surface of an unvalidated query interface; the cost accepted is the discipline of maintaining a complete PQL and updating it whenever client code changes.

A concrete failure mode occurs when a client sends an unregistered operation while persisted_queries.safelist.enabled is true and persisted_queries.log_unknown is also true. The router rejects the request and emits a log entry with the exact signal WARN unknown operation operation_body=..., as shown in the source: 2023-08-02T11:51:59.833534Z WARN [trace_id=...] unknown operation operation_body="query ExampleQuery {\n me {\n id\n }\n}\n". An operator monitoring the router’s logs would see this warning, identify the unregistered operation, and then either publish it to the PQL or contact the client team to stop sending it. This log serves as the primary diagnostic tool for ensuring the safelist remains complete.

Failure modes — what breaks, what catches it

Client name mismatch on operation execution

  • Trigger — A request includes a PQL-specified ID, but the apollographql-client-name header does not match the clientName field of the only operation with that ID, and no fallback operation with that ID and no client name exists in the PQL.
  • Guard — No explicit named guard; the router’s internal logic checks the header against the operation’s clientName.
  • Posture — fail-closed: the router refuses the write (rejects the operation).
  • Operator signal — If log_unknown is true, the router logs the line unknown operation operation_body=... for the rejected request.
  • Recovery — The client must send the correct apollographql-client-name header, or the operation must be republished without a clientName.

Operation string mismatch due to client library automatic field addition

  • Trigger — The client library (e.g., Apollo Client) automatically adds __typename to an operation, but the persisted operation in the PQL was registered without that field. The client sends the full operation string (when safelist.require_id is false), causing an exact‑string mismatch.
  • Guard — No explicit guard; the router performs an exact string comparison against registered manifests.
  • Posture — fail-closed: the router rejects the operation if safelisting is enabled.
  • Operator signal — The router logs unknown operation operation_body="query ... { __typename ... }" in its logs (example log line from source: 2023-08-02T11:51:59.833534Z WARN ... unknown operation operation_body="query ExampleQuery {\n me {\n id\n }\n}\n").
  • Recovery — Regenerate the manifest using a tool that accounts for client library augmentations, or disable automatic __typename addition in the client.

Router unable to fetch persisted query manifest from Uplink (offline license)

  • Trigger — The router runs with an offline Enterprise license and cannot reach Apollo Uplink, yet persisted_queries.safelist.enabled is true.
  • Guard — No explicit guard; the source states the feature “can’t be used” in this scenario.
  • Posture — fail-hard: the router cannot perform safelisting as designed – it may fail to start or reject all operations.
  • Operator signal — No specific log line given in the source; likely an error about Uplink connectivity or startup failure.
  • Recovery — Configure local_manifests with a downloaded manifest file, or switch to an online license.

Client sends operation ID of a deleted operation

  • Trigger — An operation is deleted from the PQL, but a client continues to send its PQL-specified ID.
  • Guard — No explicit named guard; the router’s validation rejects IDs not present in the current PQL.
  • Posture — fail-closed: the router rejects the operation regardless of security level.
  • Operator signal — If log_unknown is true, the router logs unknown operation operation_body=... (the ID is treated as unknown because the operation no longer exists).
  • Recovery — Either update the client to use a valid ID, or republish the deleted operation using rover persisted-queries publish (potentially with --for-client-name).

Local manifest file path incorrect or missing

  • Trigger — The local_manifests configuration points to a file that does not exist or is unreadable.
  • Guard — No explicit guard; the router “doesn’t reload the manifest from the file system” and must restart to apply changes – a missing file at startup likely prevents manifest loading.
  • Posture — fail-hard: the router may fail to start or operate without a PQL.
  • Operator signal — No exact log line given in the source; an operator would see an error about file not found or a parse failure in the router logs.
  • Recovery — Correct the file path and restart the router.
STUDY AIDSevidence-backed memory techniques
Recall check

In Persisted Operations, what triggers Client name mismatch on operation execution — and how is it caught?

Show answer

A request includes a PQL-specified ID

Recall check

In Persisted Operations, what triggers Operation string mismatch due to client library automatic field addition — and how is it caught?

Show answer

The client library (e.g.

06. Registry And Usage Reporting

ELI5 — the plain-language version

Imagine a master recipe book that every chef in a kitchen submits their recipe to before the meal is served. This book is the schema registry, and its job is to make sure no change ruins the dinner for the people eating it—the clients. The registry stores each subgraph’s blueprint, runs a composition test to see if all recipes still combine into one coherent meal, and then checks if any ingredient being removed actually gets eaten by someone at the table. It does not just flag every theoretical break—it looks at real data: which client ordered which dish. So if a field called description is taken off the menu, the registry only calls it breaking if a client actually asked for description in the recent retention window. That per-consumer analytics gives the team a clear picture: they can deprecate safely, knowing exactly who will be affected. Without this usage-aware check, a harmless change would trigger false alarms, or worse, a truly breaking field removal would sneak through and crash a client’s order the next morning—a failure that feels like serving a finished dish with a missing ingredient that a diner was counting on.

A schema registry stores every subgraph's schema definition. It runs composition checks before a change ships. This means each subgraph's schema is published and validated. The registry also detects breaking changes by comparing the new schema to the latest version. But it does more than check the type system alone. It checks against real client usage. The registry knows which fields each client queries. So a removal is only considered breaking if someone actually asked for that field in the retention window. This per-field usage data works like per-consumer analytics. It gives the team a clear view of what operations depend on a field. That makes deprecation and migration decisions grounded in actual usage, not speculation. The registry stores usage reporting that flows back from the router. This telemetry powers safe deprecations and incident diagnosis. Without this usage data, teams would have to guess which fields are still used. With it, they have real data. Composition checks happen in continuous integration. Publishing to the registry only happens after checks pass. This gate turns the registry into governance, not just documentation. When a field is deprecated, the team watches its real usage in the registry. They contact remaining consumers before removal. This lifecycle is driven by data.

A query from a real consumer omitting the description field, so its removal is not breaking.

python
query Feed_AllPosts {
  posts {
    id
    title
    content
    # Note: Field "description" is not requested in this query
  }
}
System design — mechanism, invariant, trade-off

The subsystem operates as a staged pipeline, structured so that no subgraph schema reaches production without passing three sequentially enforced gates. First, every schema pull request triggers CI linting against written design standards (naming, deprecation reasons, description coverage). Second, the registry runs composition checks — does the supergraph still compose under the federation spec. Third, and critically, the registry performs usage-aware breaking‑change checks: it compares the proposed schema against real client operations collected in the retention window, using per‑field, per‑client analytics. Only after all three gates pass does hive schema:promote push the composed artifact from staging to production via the CDN. A failure at any gate blocks the publication; the operator sees a rejected check in CI, and the Hive Console reports the specific failing rule.

The invariant the design guarantees is that a schema change is considered not breaking if no active client actually uses the affected field within the measured retention window. This is expressed in the source as “usage‑aware breaking‑change checks — does this change break any operation a real client ran recently.” The guarantee is not type‑system correctness but operational continuity: a theoretically breaking removal is safe to publish when real consumption of that field is zero. The registry preserves the promise that published schemas never cause a live client’s operations to fail, because it correlates every change against actual query patterns.

The key trade‑off is between type‑system purity and practical safety. The obvious alternative is to reject every theoretically breaking change regardless of consumption — a static diff against the SDL alone. That alternative would force teams to either never remove fields, or to bypass the registry entirely. The usage‑aware check rejects that approach because it would block harmless deprecations and generate unnecessary friction, making the governance layer a hindrance rather than a safeguard. By checking against real client usage, the system avoids the cost of false‑positive breakage alerts and allows incremental evolution of the schema without requiring every consumer to migrate before a field is cleaned up.

A concrete failure mode occurs when a team attempts to remove a field that a single, long‑dormant client still queries once a month. The registry’s retention window includes that sporadic request, so the usage‑aware check fires: the operator sees a “schema check failed” signal in CI, with a Hive Console message such as “Breaking change detected — field Post.description is still queried by client legacy‑cms (last seen 23 days ago).” The publish is blocked, and the operator must decide whether to contact the owner of legacy‑cms or request a manual approval via the Hive App’s “approve breaking changes” feature. No change reaches the production supergraph until that signal is resolved, preserving the invariant that no live client breaks.

Failure modes — what breaks, what catches it

Here is a deep failure-mode analysis of the Registry and Usage Reporting subsystem, grounded exclusively in the provided source text.


Stale usage data silences a breaking change

  • Trigger — The per-client usage retention window expires or the registry stops receiving telemetry. A subgraph removes a field that no client has queried inside the window, even though a consumer still depends on it in a long-lived, unpinned operation.

  • Guard — No guard is shown in the source. The source states the registry “detects breaking changes by comparing the new schema to the latest version” and runs “usage-aware breaking-change checks against the registry — does this change break any operation a real client ran recently.” But it does not describe a retention‑window lock, a data‑freshness check, or a fallback to type‑system analysis when usage data is absent.

  • Posture — Fail‑soft. The check passes, the subgraph is published, and the deployment proceeds. The registry continues operating, but the supergraph now breaks a real consumer silently.

  • Operator signal — The absence of any warning. The field removal is flagged as non-breaking in the schema check’s changelog, and the per‑field usage count for that field is reported as zero. No error or alert is raised.

  • Recovery — Manual recovery only. The operator must re‑publish the field, contact the affected consumer, and backfill usage data. No retry or automatic fallback exists because the break was not detected at publish time.


Subgraph composition failure bypasses the CI gate

  • Trigger — A subgraph change introduces a key conflict, missing @key directive, or incompatible federation entity definition. The composition phase of the CI gate fails.

  • Guard — The “composition — does the supergraph still compose” check inside the CI gate. This is exactly the validation step described: “Every schema PR runs: schema linting … composition — does the supergraph still compose.”

  • Posture — Fail‑hard. The CI gate blocks publishing: “Publishing to the registry happens only from CI after checks pass, never from laptops.” The subgraph is rejected before it reaches the registry.

  • Operator signal — The CI job output prints a composition failure error. The schema check in Hive Console displays a “Composition Checks” failure with details of the federation violation.

  • Recovery — Automatic after fix. The developer must correct the subgraph SDL and re‑run the CI pipeline. No fallback value is used; the change cannot proceed until composition succeeds.


Registry service outage halts all publishing

  • Trigger — The registry’s API or artifact CDN becomes unreachable during a CI run. The CI gate cannot run composition or usage checks because it cannot fetch the latest schema or publish the new version.

  • Guard — No guard is shown in the source. The text describes the registry as “the engine” and “necessary”, but does not mention a retry loop, a local cache of the latest schema, or a fallback mode. The CI gate is entirely dependent on a live registry.

  • Posture — Fail‑closed. The CI gate cannot validate or publish, so the subgraph change is refused. All pushes are blocked until the registry returns.

  • Operator signal — The CI job fails with a connection timeout or HTTP 503 error. The Hive Console becomes inaccessible, and any polling for schema checks returns errors.

  • Recovery — Manual step: operator restores the registry service (e.g., redeploy or restart). Once the registry is back, the developer reruns the CI job. No automatic retry with backoff is described.


Manual bypass of CI gate publishes a breaking change

  • Trigger — A developer with high‑level registry access uses the Hive CLI or the console UI to publish a subgraph directly from a laptop, circumventing the CI gate. The schema change is a field removal that breaks a real client.

  • Guard — “Access governance completes it: who may publish subgraphs, who may approve breaking changes.” The source states that publishing “never from laptops” is enforced, but the guard relies on permissions set by the governance rules. There is no technical guard shown that prevents a privileged actor from publishing without CI checks; the guard is procedural.

  • Posture — Fail‑hard (from the subsystem’s perspective). The registry accepts the write. The supergraph composes, the new schema is served by the router, and existing queries fail. The “posture” of the registry itself is fail‑open — it allowed the write — but the effect on consumers is a hard break.

  • Operator signal — The absence of any CI‑gate failure. The registry logs show a direct publish from an IP or user token not originating from CI. Per‑field usage counters may spike with client errors immediately after deployment.

  • Recovery — Manual rollback using hive schema:promote to revert to a previous version: “roll back a target to a previously published schema version: hive schema:promote --version <id> --to the-guild/hive/production”. No automatic retry is triggered because the break was not detected at publish time.


False‑positive breaking change blocks a valid additive change

  • Trigger — An additive field is introduced, but the usage‑aware check misattributes a client operation as dependent on a removed field because of a stale or corrupted usage snapshot. The check flags a non‑existing breaking change.

  • Guard — The guard is the “usage-aware breaking-change checks” that rely on “per-field, per-client usage.” However, the source does not describe any validation that the usage data itself is correct, nor a mechanism to skip or override the false positive automatically. The only override mentioned is manual approval: “Sometimes, you want to allow a breaking change to be published … by manually approving a failed schema check on the Hive App.”

  • Posture — Fail‑closed. The CI gate rejects the schema change, blocking the additive field from being published. The registry refuses the write.

  • Operator signal — A “Schema Changes” diff in Hive Console shows a breaking change for a field that the operator knows is new. The changelog lists a false dependency.

  • Recovery — Manual approval. An architect or domain owner must review the false positive, approve the schema check in Hive Console, and then re‑trigger the publish. The source identifies the approval process: “approving a schema check … retain that approval within the context of a pull/merge request.” No automatic fallback is provided.

STUDY AIDSevidence-backed memory techniques
Recall check

In Registry And Usage Reporting, what triggers Stale usage data silences a breaking change — and how is it caught?

Show answer

The per-client usage retention window expires or the registry stops receiving telemetry.

Recall check

In Registry And Usage Reporting, what triggers Subgraph composition failure bypasses the CI gate — and how is it caught?

Show answer

A subgraph change introduces a key conflict, missing `@key` directive, or incompatible federation entity definition.

07. Authorization In The Graph

ELI5 — the plain-language version

Imagine a building where a front-door guard checks everyone’s ID (the gateway validating a JWT token) and lets them in, but inside, each room has its own lock that only opens if the person’s ID has the right key. This system is for ensuring that even after you’re in the building, you can only see or change the specific rooms (fields or types) you have permission for.

After the guard verifies your identity and passes your claims (like a keychain of badges) to the rest of the building, the router walks through the GraphQL request. For each field, it checks the locks declared in the schema: a directive like @authenticated means “only let someone in if they passed the front-door check,” and @policy goes further—it sends your badges to a coprocessor or Rhai script, which says “yes, this person has the ‘read_profile’ badge” or “no, they don’t have the ‘read_credit_card’ badge.” If a field fails, the router hides it, and if every field in a subgraph query fails, the router stops the whole request to that subgraph, saving work.

The trickiest part is how multiple locks work together: if a field has @policy(policies: [["policy1"], ["policy2"]]), it means the person needs either policy1 or policy2 (OR logic). But if you write @policy(policies: [["policy1", "policy2"]]), they need both (AND logic). The router merges these from different subgraphs using AND logic, so a field coming from two subgraphs might combine them. Without this system, a user could walk past the front guard and then open every room, reading credit card numbers or private profiles they shouldn’t see—exactly the kind of failure a beginner would feel if their sensitive data leaked out.

Authentication and authorization work at two separate places. The outer layer is the gateway. It validates a JWT token and forwards its claims to the rest of the system. This check happens before any GraphQL request is processed.

Inside the graph itself, fine‑grained authorization is declared as schema directives. For example, the @authenticated directive marks a field or type as requiring authentication. The @policy directive goes further, checking custom policies through a coprocessor or Rhai script. These policies are evaluated during execution. If a field fails authorization, the router returns null for that field and adds an error with the code "UNAUTHORIZED_FIELD_OR_TYPE". Standard GraphQL null propagation applies. When every requested field needs authentication and a request is unauthenticated, the router returns data as null.

A management layer can still contribute request validation at the edge. A policy like validate‑graphql‑request can set a maximum size and depth for incoming GraphQL requests. It can also use an authorize rule to allow or deny specific operations and paths. For instance, a rule might check the request IP address and deny the deleteUser mutation unless the IP matches a certain value. This validation happens before any GraphQL execution begins. Together these layers ensure that only properly authenticated and authorized requests reach the graph.

Edge validation of incoming GraphQL requests using validate-graphql-request policy with IP-based authorization.

python
<validate-graphql-request error-variable-name="name" max-size="102400" max-depth="4"> <authorize> <rule path="/Mutation/deleteUser" action="@(context.Request.IpAddress <> "198.51.100.1" ? "deny" : "allow")" /> </authorize> </validate-graphql-request>
System design — mechanism, invariant, trade-off

The authorization subsystem operates in two clearly demarcated layers. First, the outer gateway validates a JWT token and forwards its claims; this check occurs before any GraphQL processing. Inside the graph, fine‑grained authorization is declared via schema directives such as @authenticated or @policy. During execution, the router evaluates these directives: for @authenticated, it checks that the request carries a valid token; for @policy, it invokes a coprocessor or Rhai script to evaluate custom policies. If a field or type fails authorization, the router either filters it (returning null) or, when reject_unauthorized is enabled, rejects the entire query and returns the list of affected paths. The default behavior places filtered paths in the errors block of the GraphQL response, but the errors.response option can move them to extensions or suppress them entirely. The dry_run option allows operators to simulate enforcement without modifying the query.

The invariant preserved is that the router guarantees that only authorized fields and types are returned to the client, while unauthorized fields are either removed (resulting in null) or cause the whole query to fail if reject_unauthorized is true. This extends to composed fields across subgraphs: when the same field carries different directives from different subgraphs, composition uses AND logic (e.g., @authenticated in one subgraph and @requiresScopes in another requires both). The system also ensures that if no field of a subgraph query passes its policies, the router stops further processing, preventing unnecessary subgraph calls—a performance gain explicitly noted in the source.

The key trade‑off is declarative per‑field directives versus an imperative gateway‑centric approach. The design rejects the obvious alternative of performing all authorization inside the outer gateway, which would require the gateway to understand every subgraph’s schema and policies, creating tight coupling and a single point of change. Instead, enforcement is pushed into the graph layer via schema directives. This rejects a monolithic gateway and avoids the cost of maintaining duplicate authorization logic across subgraphs and the gateway; each subgraph declares its own rules, and the router composes them. The cost of this choice is the need for a coprocessor or Rhai script to evaluate @policy policies, but the trade‑off yields flexible, decentralized enforcement that scales with federation.

A concrete failure mode: a request with no valid token attempts to query a field marked @authenticated (e.g., Product.id). The router, during execution, sees that the token is absent and thus the directive fails. If reject_unauthorized is false and errors.response is "errors" (the default), the operator sees a GraphQL response with null for the unauthorized field and an entry in the errors array containing the path (e.g., /product/id). If reject_unauthorized is true, the entire query fails with an errors block and no data block, and the path list is included. Alternatively, with dry_run: true, the operator would receive the list of unauthorized paths in the response but the query would still execute fully, allowing evaluation without impacting traffic.

Failure modes — what breaks, what catches it

Query exceeds max-size or max-depth

  • Trigger — A GraphQL request that exceeds the max-size (e.g., 100 kb) or max-depth (e.g., 4) configured in the <validate-graphql-request> element.
  • Guard — The <validate-graphql-request> policy block; the request is rejected before any field resolution.
  • Posture — fail-hard — the entire request is failed with an errors block and no data block.
  • Operator signal — The GraphQLErrors variable is set, and the error block contains validation failure details.
  • Recovery — The client must resend a query below the size and depth limits; no automatic retry.

Unauthenticated request targets a field or type protected by @authenticated

  • Trigger — A request without valid JWT claims (or with missing authentication) attempts to query a field or type marked with the @authenticated directive.
  • Guard — The @authenticated directive itself; the router filters the unauthorized field from the response.
  • Posture — fail-soft — the query executes but the unauthorized fields are removed, and an "UNAUTHORIZED_FIELD_OR_TYPE" error is added at the affected path.
  • Operator signal — The response includes an error with the path (e.g., /posts/... on PrivateBlog for interfaces) and the filtered result omits the restricted fields.
  • Recovery — The client must re-authenticate to obtain proper claims; no automatic retry.

@policy coprocessor or Rhai script returns null (crashes or is missing)

  • Trigger — The coprocessor or Rhai script that evaluates @policy policies fails, leaving the policy value as null.
  • Guard — The default behavior: “If the value is left to null, it will be treated as false by the router.”
  • Posture — fail-closed — the field or type is treated as unauthorized, so the router filters it out.
  • Operator signal — The field is removed from the response; an "UNAUTHORIZED_FIELD_OR_TYPE" error may appear (if configured).
  • Recovery — The operator must fix the coprocessor or script; no automatic retry.

reject_unauthorized is enabled and any authorization directive fails

  • Trigger — Any authorization directive (e.g., @authenticated, @policy) fails or results in field filtering, and reject_unauthorized: true is set in the configuration.
  • Guard — The reject_unauthorized option, which causes the entire query to be rejected.
  • Posture — fail-hard — the whole request is rejected, not just the unauthorized fields.
  • Operator signal — The response contains a list of paths that were affected by the failure.
  • Recovery — The client must adjust the query or provide proper credentials; no automatic retry.

Introspection accidentally enabled, revealing fields that should be hidden

  • Trigger — Introspection is turned on in the router configuration (contrary to the default best practice of being off).
  • Guard — None explicitly provided; the source notes “authorization directives don't affect introspection” and “If introspection might reveal too much information about internal types, then be sure it hasn't been enabled.”
  • Posture — fail-soft — the router still enforces authorization at runtime, but the schema is exposed via introspection, potentially revealing field names and types.
  • Operator signal — An external party can query __schema or __type and see fields that require authorization (though directive definitions are hidden).
  • Recovery — The operator must disable introspection in the router configuration; no automatic recovery.
STUDY AIDSevidence-backed memory techniques
Recall check

In Authorization In The Graph, what triggers Query exceeds max-size or max-depth — and how is it caught?

Show answer

A GraphQL request that exceeds the `max-size` (e.g., 100 kb) or `max-depth` (e.g., 4) configured in the `<validate-graphql-request>` element.

Recall check

In Authorization In The Graph, what triggers Unauthenticated request targets a field or type protected by @authenticated — and how is it caught?

Show answer

A request without valid JWT claims (or with missing authentication) attempts to query a field or type marked with the `@authenticated` directive.

08. Caching A Single Endpoint

ELI5 — the plain-language version

Imagine the router as a postal sorting office that handles letters from many customers. Each letter is a request to a subgraph. This subsystem is like the office's automated sorting machine that prepares each letter before it gets shipped out, making sure the subgraph isn't overwhelmed and that no identical letters are sent twice.

The machine runs through a strict order of steps. First, it combines identical letters using a feature called deduplicate_query — if two customers ask for the same thing, only one letter is sent, and the answer is shared. Next, it wraps the letter in a lighter envelope using compression (like gzip) to save space, though you can turn off compression for certain subgraphs with the identity keyword. Then it sets a speed limit using rate limiting (e.g., 10 letters per 5 seconds) so the subgraph isn't flooded. Finally, it hands the letter to a delivery truck that is limited by concurrency_limit to never send too many at once.

The trickiest detail is that every step must happen in a fixed order — compression comes after rate limiting, not before. If you compressed a letter before checking whether it's allowed, you'd waste work on a request that might be rejected anyway. The context lists the exact sequence: prepare, variable deduplication, query deduplication, timeout, rate limiting, compression, then send.

Without this subsystem, the subgraph would receive duplicate letters, oversized envelopes, and too many at once — like a delivery truck jammed with thousands of identical packages. Beginners would feel the failure as slow loading times, errors, or the entire service freezing under the load.

In Graph Query Language, every request is a POST to a single URL, so HTTP caching does not work the same way. There is no URL that identifies a specific object. Instead, the API exposes globally unique identifiers for objects. The id field is a common choice. This gives clients a consistent key to build their own cache, keyed by the object rather than by the request. The trade-off is that caching moves from the network layer to the application layer.

Behind the scenes, traffic shaping controls protect the subgraph layer. These controls run in a fixed order. First, the subgraph request is prepared. Then variable deduplication happens. Next, query deduplication occurs. After that, a timeout is applied. Then rate limiting checks the flow. Compression follows, and finally the request is sent to the subgraph. The order is always the same, no matter how you write the configuration.

Additional controls set concurrency limits and timeouts. A router timeout of fifty seconds returns a five hundred four status code. Subgraph timeouts apply to each individual fetch request. So caching relies on unique object IDs, and traffic shaping keeps everything running smoothly.

Traffic shaping configuration for subgraph request handling.

yaml
traffic_shaping:
  all:
    deduplicate_query: true
System design — mechanism, invariant, trade-off

In a system where every GraphQL request is a POST to a single URL, HTTP-layer caching breaks down because there is no resource-specific URL to key on. The design compensates by requiring the schema to expose a globally unique identifier—conventionally the id field—so that clients can build an application‑layer cache keyed by object identity rather than by request. This choice shifts caching from the network to the application layer, but that shift is necessary to avoid the cost of maintaining a separate endpoint per object, which would negate GraphQL’s single‑endpoint simplicity.

Behind the scenes, the subgraph traffic‑shaping subsystem enforces a fixed order of operations to protect the backend. After preparing the subgraph request, the pipeline applies variable deduplication (always active), then query deduplication (disabled by default), followed by timeout, rate limiting, and finally compression before sending the request. The ordering guarantees that deduplication happens before any throttling or compression, ensuring that duplicate work is eliminated before load limits are applied. If any step fails—for example, if a timeout fires or a rate limiter rejects the request—the pipeline stops and propagates the error back, preventing wasted subgraph calls.

The invariant preserved by the design is that each subgraph request is minimal and unique: variable deduplication prevents the same entity within a federated query from being sent repeatedly, and query deduplication collapses identical concurrent requests (same HTTP path, headers, and body) into a single subgraph call. This reduces subgraph load without sacrificing correctness, because the deduplication logic relies on exact equality of the request payload. The trade-off is that the router must hold dependent requests while waiting for the first result, increasing memory pressure and latency for the buffered queries. The obvious alternative—relying on HTTP‑level caching with per‑object URLs—is rejected because GraphQL’s single‑endpoint nature makes it impossible; adopting it would require breaking the schema into hundreds of small endpoints, defeating the purpose of a unified query language. The chosen design avoids that fragmentation cost while still achieving reduction in subgraph traffic.

A concrete failure mode occurs when query deduplication is enabled but the subgraph behaves non‑idempotently—for example, it returns a different timestamp each time for the same query. The router will reuse the first result for all buffered requests, causing clients to see stale or incorrect data. The operator would observe a spike in user complaints about inconsistent responses, and in logs they would see entries indicating that a subgraph query was deduplicated (for instance, a log line containing deduplicate_query: true followed by a single outgoing request with multiple incoming dependencies). Additionally, if the subgraph’s non‑idempotent behavior causes internal errors when the reused result doesn’t match expectations, the router may return an HTTP 503 status (the standard signal for subgraph rate‑limit rejection or timeouts), giving operators a clear numerical indicator to investigate.

Failure modes — what breaks, what catches it

Connection Pool Idle Timeout

  • Trigger — A subgraph connection remains idle longer than pool_idle_timeout (default 15 s).
  • Guard — Lazy eviction mechanism: when the router fetches a connection from the pool, it discards any connection that has been idle longer than pool_idle_timeout and opens a fresh one.
  • Posture — Fail‑soft: the router degrades by creating a new connection, which adds latency to the first request after idle; the run continues.
  • Operator signal — No explicit log from the source; the observable signal is a sudden increase in the rate of TCP handshake metrics (e.g., apollo_router_connections_created) and a corresponding drop in connection reuse.
  • Recovery — Automatic: the next request triggers the eviction and a fresh connection is established. An operator can adjust pool_idle_timeout per subgraph or set it to null to disable idle eviction entirely.

HTTP/2 Keep‑Alive PING Timeout

  • Trigger — A subgraph connection becomes silently broken (e.g., network timeout or load balancer idle timeout) and does not respond to a PING frame within experimental_http2_keep_alive_timeout (default 20 s).
  • Guard — The router sends periodic PING frames after experimental_http2_keep_alive_interval (e.g., 30 s) and closes the connection if no PONG is received within experimental_http2_keep_alive_timeout.
  • Posture — Fail‑soft: the broken connection is closed; the router opens a new connection on the next request, continuing operation.
  • Operator signal — The source states “the connection is closed”; operators would observe increased TCP connection closes and a rise in apollo_router_connections_closed metrics.
  • Recovery — Automatic: the next request to that subgraph opens a fresh connection. An operator can tune experimental_http2_keep_alive_interval and experimental_http2_keep_alive_timeout.

Global Rate Limiting

  • Trigger — The subgraph receives more requests than the configured capacity within interval (e.g., 10 requests in 5 s).
  • Guardglobal_rate_limit with fields capacity and interval; excess requests are rejected.
  • Posture — Fail‑hard: the router returns an error (presumably HTTP 429) for the rejected requests; the caller must handle the rejection.
  • Operator signal — The source says “Excess requests must be rejected”; the operator sees a spike in HTTP 429 status codes in router logs or metrics.
  • Recovery — Client‑side retry (not described in source) or manual adjustment of capacity and interval.

Router Concurrency Limit

  • Trigger — The number of concurrent requests being processed reaches concurrency_limit (e.g., 100).
  • Guard — The traffic_shaping.router.concurrency_limit setting rejects any excess concurrent request.
  • Posture — Fail‑hard: the router refuses to accept the new request immediately, aborting that request.
  • Operator signal — The source states “Excess requests must be rejected”; the operator observes HTTP 503 or similar error codes in router logs.
  • Recovery — Client‑side retry; the router resumes processing as soon as concurrency drops below the limit. Operators may increase concurrency_limit.

Request Body Compression Mismatch

  • Triggercompression is set to gzip (or br, deflate) for a subgraph that does not accept compressed request bodies; the router adds a content-encoding: gzip header.
  • Guard — No automatic guard exists in the source. The router always sets accept-encoding to indicate it accepts compressed responses, but it does not verify subgraph support for outgoing compression. The only mechanism is the identity keyword, which disables compression per subgraph.
  • Posture — Fail‑hard: the subgraph may reject the request or respond with an error, causing the router to fail that subgraph call.
  • Operator signal — The subgraph returns a non‑2xx status; router logs show errors like subgraph request failed (exact log not in source, but implied).
  • Recovery — Manual intervention: the operator must add compression: identity under the affected subgraph’s configuration.

Query Deduplication Stale Result

  • Triggerdeduplicate_query: true is enabled, and multiple identical subgraph requests are in flight concurrently. The router buffers them and reuses the first response, but the subgraph data changes after that first response is received but before the buffered requests are served.
  • Guard — None described in the source. Query deduplication is a best‑effort optimization with no staleness detection or invalidation.
  • Posture — Fail‑soft: the router returns the stale result to the dependent requests, degrading correctness while continuing normal operation.
  • Operator signal — The source does not provide a signal; operators may notice intermittent data inconsistencies or complaints from downstream consumers. No log or metric is defined.
  • Recovery — Manual: disable deduplicate_query for the affected subgraph, or accept the trade‑off if the data is immutable for the duration of the deduplication window.
STUDY AIDSevidence-backed memory techniques
Recall check

In Caching A Single Endpoint, what triggers Connection Pool Idle Timeout — and how is it caught?

Show answer

A subgraph connection remains idle longer than `pool_idle_timeout` (default 15 s).

Recall check

In Caching A Single Endpoint, what triggers HTTP/2 Keep‑Alive PING Timeout — and how is it caught?

Show answer

A subgraph connection becomes silently broken (e.g.

09. The Two Layer Edge In Practice

ELI5 — the plain-language version

Imagine an office building with two separate security teams. One team works the front entrance: they check everyone’s ID, make sure they are on the list, and limit how many people can come in at once. The other team works inside the floors: they check that each person is allowed to enter specific rooms and can only make certain requests. For an enterprise, this two‑layer edge design is about splitting the job of protecting your data between a general gatekeeper that handles who gets in and a specialist that controls exactly what they can do once inside.

The front‑entrance team – in the real system called API management – does the first check: it validates tokens and credentials, manages quotas per partner, and handles onboarding new consumers through a portal. It logs every entry and meters usage. After that, the person enters the building. Now the inside security team, which is the router and the registry, looks at each request more carefully. It enforces cost limits so a query cannot be too expensive, checks field‑level authorization declared in the schema (like a room‑by‑room permission list), and reports which fields each client actually uses. The two teams share a seam: persisted operations – pre‑registered query blueprints – that both layers can police. For example, the front entrance can even rate‑limit a specific registered operation if it is being abused.

The trickiest part is that the two layers must coordinate without duplicating effort or leaving gaps. The front‑entrance team does not know about schema permissions; it simply trusts the token claims it has forwarded. The inside security team relies on those claims to enforce fine‑grained authorization. If the front entrance incorrectly forwards claims or lets someone in with an expired token, the inside layer might still deny access, but the damage of a bad initial check can open the door to denial‑of‑service or quota bypass. Without this two‑layer design, an enterprise would either let everyone who passes the front desk roam freely (no schema‑aware governance) or force a single gatekeeper to understand both transport and graph details, leading to brittle rules and missed field‑level policies. A beginner would feel the concrete failure when a partner who is allowed in makes a query that accidentally retrieves all customer data because the inside layer had no field‑level guard – or conversely, when a legitimate query is blocked because the front desk did not forward the correct claims.

Your enterprise needs two layers working as one design. API management owns the transport edge. It validates tokens and checks credentials. It manages quotas per consumer and handles partner onboarding through the portal. The router and the registry own the graph. They enforce cost limits on queries. They enforce field-level authorization declared in the schema. They report usage per field and per client. They manage how the schema evolves through version history. Persisted operations create a seam that both layers can police. The management layer can even rate-limit a specific registered operation. The failure mode is buying one layer and thinking it covers the other. The same architecture is needed with or without a branded product in front.

Importing a GraphQL API into the API Management transport layer using the Azure CLI.

python
APIMServiceName="apim-hello-world"
ResourceGroupName="myResourceGroup"
APIId="my-graphql-api"
APIPath="myapi"
DisplayName="MyGraphQLAPI"
SpecificationFormat="GraphQL"
SpecificationURL="<GraphQL backend endpoint>"
az apim api import \
    --path $APIPath \
    --resource-group $ResourceGroupName \
    --service-name $APIMServiceName --api-id $APIId \
    --display-name $DisplayName \
    --specification-format $SpecificationFormat \
    --specification-url $SpecificationURL
System design — mechanism, invariant, trade-off

The two-layer edge subsystem operates as a sequential handoff between two distinct governance planes. First, the API management layer—our “front door”—validates the transport: it checks OIDC/JWT tokens, enforces API keys and subscriptions per consumer, applies rate limits and spike arrest, and logs the request for consumer-level analytics. Only after this coarse authentication passes does the request proceed to the graph layer, which consists of the router (Hive Gateway) and the schema registry. There, the router enforces graph-aware policies: persisted‑operation allow‑lists restrict execution to registered document IDs, cost limits cap query complexity, and field‑level authorization declared as schema directives is evaluated at execution time. On failure at either layer—a rejected token at the front door or a disallowed operation at the router—the request is terminated immediately and the error is surfaced to the caller. The registry completes the loop by receiving usage reports from the gateway, feeding per‑field, per‑client telemetry back into future schema‑check decisions.

The central invariant preserved by this design is that the graph’s contract evolves only under usage‑aware governance. The registry’s version history and per‑field telemetry enable “usage‑aware breaking‑change gates”: a breaking change on a field that has not been called in thirty days passes, while a change breaking an active consumer fails the pipeline. This invariant is enforced by the hive schema:check mechanism, which diffs proposed schemas against the registry and incorporates real execution data. Additionally, the persisted‑operation allow‑list guarantees that only pre‑registered document IDs execute at the edge—restoring cacheability (via GET requests) and shrinking the attack surface. The entire data plane continues serving from the artifact CDN even if the registry control plane is down, ensuring availability does not depend on governance uptime.

The key trade‑off is the explicit separation of transport governance from graph governance, rejecting the alternative of treating GraphQL as “just another API” in a monolithic API management product. That alternative would yield “exactly one governed route and zero graph governance”—the APIM cannot rate‑limit an expensive query differently from a cheap one, detect a breaking schema change, or cache per operation shape. The cost this design avoids is the operational blindness that comes from an opaque POST endpoint; by splitting layers, the system gains schema‑aware cost limits, field‑level authorization, and safe schema evolution. The cost it accepts is the need for coordination: claims must be forwarded from the transport layer to the graph layer, and persisted operations must be registered in both systems so that the management layer can even rate‑limit a specific registered operation.

A concrete failure mode is stale or missing usage data leading to a false‑positive schema‑check pass. If the gateways and servers that report usage (per‑field, per‑client telemetry) fail silently—due to a network partition or a misconfiguration that stops the reporting pipeline—the registry will see zero activity on all fields. A subsequent hive schema:check on a pull request that deletes an active field will then pass the check, because the usage–gate sees no consumers. The operator would observe the CI pipeline succeeding, only to receive alerts from consumer applications after deployment when they start seeing FIELD_DEPRECATION or FIELD_NOT_FOUND errors. The tell‑tale signal in the registry dashboard would be a sudden drop to zero usage for all fields, while the gateway logs show normal traffic—a mismatch that points to a broken reporting link.

Failure modes — what breaks, what catches it

Breaking change attempt pushed to the registry

  • Trigger — A developer modifies a subgraph SDL by removing a field that is currently used by active consumers, and pushes a publish from CI.
  • Guard — The hive schema:check command runs as part of the pull request pipeline, comparing the proposed schema against the registry and consuming usage data. The check fails the pipeline because the field has active usage.
  • Posture — Fail-hard: the publish is rejected and the pipeline aborts.
  • Operator signal — The CI output contains a breaking change report listing the removed field and the number of active consumers that call it.
  • Recovery — The developer must either deprecate the field (with a reason) and wait until usage drops to zero, or manually override the check if the field is truly safe (the usage-aware check can be tuned, but source describes no automatic retry).

Client sends a query not in the persisted-operation allow-list

  • Trigger — A new or modified client application ships an operation that has not been registered with persisted documents in the Hive registry.
  • Guard — The persisted-operation allow-lists feature at the edge executes only known operation IDs; any unregistered operation is rejected.
  • Posture — Fail-closed: the edge refuses to execute the unknown query.
  • Operator signal — The client receives a GraphQL error indicating the operation is not allowed, typically with a code like PERSISTED_QUERY_NOT_FOUND (not named in source, but the rejection is explicit).
  • Recovery — The client team must register the exact document in the Hive registry for the new app version, then redeploy the client.

Query exceeds the depth or complexity limit

  • Trigger — A consumer sends a deeply nested or computationally expensive GraphQL query that surpasses the configured thresholds.
  • Guard — The Depth, complexity, and cost limits enforced by the Hive Gateway (or the router) at the graph layer. The source states the router enforces these limits.
  • Posture — Fail-hard: the query is rejected immediately and not forwarded to the subgraphs.
  • Operator signal — The consumer receives a GraphQL error with a message such as "Query complexity exceeds maximum allowed" (source does not give the exact string, but the rejection is documented).
  • Recovery — The consumer must rewrite the query to stay within the limits, or the platform team can adjust the thresholds (no automatic retry is described).

Subgraph publish fails composition

  • Trigger — A team publishes a new version of a subgraph schema that is incompatible with other subgraphs (e.g., a type conflict in a federated graph).
  • Guard — The supergraph composition step in the Hive registry runs as part of the publish; it rejecting publishes that do not compose.
  • Posture — Fail-hard: the publish is rejected and the registry does not update the supergraph.
  • Operator signal — The CI output or registry API returns a composition error detailing the conflict (source mentions “rejecting publishes that do not compose” without the exact error format).
  • Recovery — The publishing team must resolve the incompatibility and republish a corrected subgraph schema; no automatic retry is provided.

Token validation fails at the API management front door

  • Trigger — A consumer presents an expired or malformed JWT token when calling the GraphQL endpoint.
  • Guard — No guard shown in source. The source only describes the capability: “Token validated at the front door; claims forwarded.” It does not name a specific function, exception handler, or retry for a failed validation.
  • Posture — Fail-closed: the request is denied (the API management edge does not forward invalid tokens).
  • Operator signal — The consumer receives an HTTP 401 Unauthorized response. The API management logs show a token validation failure for that consumer.
  • Recovery — The consumer must obtain a fresh token from the identity provider and retry. No automatic mechanism is described in the source.

Usage reporting telemetry silent drop

  • Trigger — A transient network outage or registry control-plane failure prevents the Hive Gateway from forwarding usage data (per-field, per-client analytics) back to the Hive registry.
  • Guard — No guard shown in source. The source says “gateways and servers report which operations, fields, and clients actually execute” but does not mention a retry, buffer, or fallback for failed reporting.
  • Posture — Fail-soft: the gateway continues to serve traffic, but usage telemetry is lost for the duration of the outage. The graph platform operates without fresh analytics.
  • Operator signal — The Hive registry dashboard shows a gap in usage data for the affected period. No error is surfaced to end users.
  • Recovery — Manual investigation and re‑establishment of the connection are required. No automatic replay is described; the operator must wait for the network to heal and then verify that reporting resumes.
STUDY AIDSevidence-backed memory techniques
Recall check

In The Two Layer Edge In Practice, what triggers Breaking change attempt pushed to the registry — and how is it caught?

Show answer

A developer modifies a subgraph SDL by removing a field that is currently used by active consumers, and pushes a publish from CI.

Recall check

In The Two Layer Edge In Practice, what triggers Client sends a query not in the persisted-operation allow-list — and how is it caught?

Show answer

A new or modified client application ships an operation that has not been registered with `persisted documents` in the Hive registry.

Checkpoint — answer before revealing1 of 4
What does an agentic system do that a standard language model cannot?
Put this into practiceRecalling beats rereading — retrieval practice is the best-supported technique in the evidence base.