GraphQL Returns 200 With Your Error Inside It

Every response is a success as far as HTTP is concerned. That one design decision breaks monitoring, retries, and error handling unless you plan for it.

Share
GraphQL Returns 200 With Your Error Inside It. Abstract networking illustration in orange and dark grey on debugly.dev

The short answer

GraphQL returns HTTP 200 for almost everything, including resolver errors, validation failures, and partial results.

{
  "data": { "user": null },
  "errors": [{
    "message": "Database connection failed",
    "path": ["user"],
    "extensions": { "code": "INTERNAL_SERVER_ERROR" }
  }]
}

Status 200. Your monitoring says the endpoint is healthy. Your client's res.ok is true. Your retry logic never fires.

Always check errors before using data:

const { data, errors } = await res.json();
if (errors?.length) {
  throw new GraphQLError(errors[0].message, { errors, path: errors[0].path });
}

Why this design exists

A GraphQL query can request several things at once. If one resolver fails and three succeed, what status code represents that?

The specification's answer is to return what succeeded in data, what failed in errors, and let the client decide. That is coherent, and it means HTTP status stops being a health signal.

The consequences are what this post is about.

The four consequences

Monitoring is blind

Your dashboard shows a 100 percent success rate while every request contains an error.

You need to inspect the body:

app.use("/graphql", (req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    if (body?.errors?.length) {
      metrics.increment("graphql.errors", {
        operation: req.body?.operationName ?? "anonymous",
        code: body.errors[0]?.extensions?.code ?? "UNKNOWN",
      });
      logger.error({
        event: "graphql.error",
        operation: req.body?.operationName,
        errors: body.errors.map(e => ({ message: e.message, path: e.path })),
      }, "graphql errors in response");
    }
    return originalJson(body);
  };
  next();
});

Most GraphQL servers have a plugin hook for this. Apollo Server has didEncounterErrors. Use it, because without it your error rate is permanently zero.

Retries do not fire

Client libraries retry on 5xx. A GraphQL 200 with an internal server error inside is not a 5xx, so nothing retries.

Handle it at the link level:

import { onError } from "@apollo/client/link/error";

const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors?.some(e => e.extensions?.code === "INTERNAL_SERVER_ERROR")) {
    return forward(operation);          // retry once
  }
  graphQLErrors?.forEach(e =>
    logger.error({ message: e.message, path: e.path, op: operation.operationName })
  );
});

Be careful about retrying mutations. A mutation that timed out may have succeeded, and retrying it duplicates the effect unless it is idempotent.

Partial data is easy to mishandle

{
  "data": {
    "order": { "id": "123", "total": 4999, "customer": null }
  },
  "errors": [{ "path": ["order", "customer"], "message": "Not authorised" }]
}

The order loaded. The customer did not. If your UI assumes customer is present, it crashes on a response that HTTP considers successful.

This is where GraphQL's nullability rules matter. A non-null field that errors propagates the null upward until it reaches a nullable field, which can null out an entire subtree from one failed leaf.

That produces a genuinely confusing symptom: a whole section of the response is missing because one deeply nested non-null field failed. The path in the error tells you the actual origin, and it is worth reading rather than reacting to the null.

Design nullability deliberately. Marking everything non-null feels safer and means a single failure can empty the response.

Errors leak internals

The default behaviour in many servers is to include the stack trace:

{
  "errors": [{
    "message": "connect ECONNREFUSED 10.0.3.44:5432",
    "extensions": {
      "exception": { "stacktrace": ["Error: connect ECONNREFUSED..."] }
    }
  }]
}

That tells an attacker your internal IP, your database port, and your framework. Mask in production:

const server = new ApolloServer({
  schema,
  includeStacktraceInErrorResponses: false,
  formatError: (formatted, error) => {
    const id = randomUUID();
    logger.error({ errorId: id, err: error }, "graphql resolver error");

    if (formatted.extensions?.code === "BAD_USER_INPUT") {
      return formatted;                   // safe to expose
    }
    return {
      message: "Internal server error",
      extensions: { code: "INTERNAL_SERVER_ERROR", errorId: id },
    };
  },
});

The errorId is the important part. The client gets something safe and a reference, you get everything in your logs, and support can join them. Same pattern as not leaking internals in HTTP error responses.

Debugging a slow query

The other thing GraphQL makes harder: one HTTP request can be hundreds of database queries.

Enable tracing to get per resolver timings. Apollo's inline trace plugin, or an OpenTelemetry instrumentation, gives you a breakdown showing which resolver consumed the time.

Expect N+1 by default. Each resolver runs independently, so a list of 50 orders each resolving a customer is 51 queries. This is the architectural default rather than a mistake, and DataLoader is the standard answer:

const customerLoader = new DataLoader(async (ids) => {
  const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });
  const byId = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => byId.get(id) ?? null);
});

Create it per request, not globally, or you serve one user's cached data to another. And return results in input key order, which the final map does. The general N+1 detection techniques apply here too, and query count assertions are especially valuable because GraphQL makes the count so unpredictable.

Log the operation name. An anonymous query in your logs is unattributable. Require names:

if (!operationName) throw new Error("operations must be named");

That is a small policy with a large payoff when you are trying to work out which client is causing load.

Query complexity, which you need before you are attacked

A nested query can be exponentially expensive:

query {
  users { orders { items { product { reviews { author { orders { ... } } } } } } }
}

Without limits, one request can consume your entire database capacity. This is a denial of service vector that is unique to GraphQL and frequently unaddressed.

import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(8), createComplexityLimitRule(1000)],
});

For a public API, also consider persisted queries, where clients send a hash of a pre-registered query rather than arbitrary GraphQL. That eliminates the whole category and reduces payload size, at the cost of a build step.

A production checklist

  • errors inspected on every client response, before data
  • Error rate metric derived from the body, not the status code
  • Stack traces suppressed in production, with an error id for correlation
  • Operation names required and logged
  • DataLoader for every to-one relationship, created per request
  • Depth and complexity limits
  • Per resolver tracing enabled
  • Introspection disabled in production for non public APIs
  • Query count assertions in tests for your heaviest operations