DEV Community

Muhammad Adnan
Muhammad Adnan

Posted on Edited on

Your GraphQL API has two error modes: leaking Postgres internals, or "Internal server error"

Try this on your GraphQL API: sign up twice with the same email.

On a lot of TypeScript + Postgres backends you get one of two responses.

Mode 1: the leak.

{
"errors": [{
"message": "duplicate key value violates unique constraint \"users_email_key\"",
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}],
"data": null
}
Enter fullscreen mode Exit fullscreen mode

Your table name, your constraint name, and a hint about your schema, sent to anyone who asks. And the client still can't act on it, because the code says "internal server error" for what is really "that email is taken".

Mode 2: the mask. Someone noticed Mode 1 and added a formatError that replaces every message with "Internal server error". Now nothing leaks, but the signup form shows "Something went wrong" for a duplicate email, an invalid input, a missing record and an actual outage alike. Your error dashboards can't tell them apart either.

Both modes have the same root cause: the resolver never decided which failures are expected (the user can fix them) and which are unexpected (you need to fix them).

The fix: two kinds of errors, decided in one place

1. Give expected failures a type and a stable code

import { GraphQLError } from "graphql";

export type ErrorCode =
| "BAD_USER_INPUT"
| "NOT_FOUND"
| "CONFLICT"
| "FORBIDDEN";

export class AppError extends GraphQLError {
constructor(code: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
super(message, { extensions: { code, ...extra } });
}
}
Enter fullscreen mode Exit fullscreen mode

The message is written for a human. The code is the contract: clients switch on it, and it never changes when you reword the message.

2. Translate database errors at the data layer, not in every resolver

Postgres already tells you exactly what went wrong through SQLSTATE codes. Translate the handful you expect, once:

const PG_UNIQUE_VIOLATION = "23505";
const PG_FOREIGN_KEY_VIOLATION = "23503";

export function translatePgError(err: unknown): never {
const e = err as { code?: string; constraint?: string };
if (e.code === PG_UNIQUE_VIOLATION) {
// Map constraint names to fields. Unknown constraints stay unexpected.
const field = UNIQUE_FIELDS[e.constraint ?? ""];
if (field) throw new AppError("CONFLICT", `That ${field} is already in use.`, { field });
}
if (e.code === PG_FOREIGN_KEY_VIOLATION) {
throw new AppError("BAD_USER_INPUT", "A referenced record does not exist.");
}
throw err; // everything else is a bug or an outage
}

const UNIQUE_FIELDS: Record<string, string> = {
users_email_key: "email",
orgs_slug_key: "slug",
};
Enter fullscreen mode Exit fullscreen mode
async function createUser(db: Pool, input: { email: string }) {
try {
const { rows } = await db.query(
"insert into users (email) values ($1) returning *",
[input.email],
);
return rows[0];
} catch (err) {
translatePgError(err);
}
}
Enter fullscreen mode Exit fullscreen mode

Note what this is not: it is not "check whether the email exists, then insert". That pattern has a race between the check and the insert. Let the unique constraint do the work and translate its error.

3. Mask only what you didn't expect

Now formatError has an easy job: pass AppErrors through untouched, mask everything else, and log the original with an id the client can quote.

import { randomUUID } from "node:crypto";
import { unwrapResolverError } from "@apollo/server/errors";

formatError(formatted, error) {
const original = unwrapResolverError(error);
if (original instanceof AppError) return formatted;
// GraphQL validation/parse errors are also safe to return as-is
if (formatted.extensions?.code && formatted.extensions.code !== "INTERNAL_SERVER_ERROR") {
return formatted;
}
const errorId = randomUUID();
logger.error({ err: original, errorId }, "unexpected resolver error");
return { message: "Internal server error", extensions: { code: "INTERNAL_SERVER_ERROR", errorId } };
}
Enter fullscreen mode Exit fullscreen mode

The same idea works in GraphQL Yoga through its maskedErrors option.

4. Test it like a contract

The test that would have caught both modes is one line of intent: "a duplicate email returns CONFLICT on the email field, and the response does not mention the constraint name."

it("returns CONFLICT for a duplicate email without leaking schema names", async () => {
await signUp({ email: "a@example.com" });
const res = await signUp({ email: "a@example.com" });

expect(res.errors?.[0].extensions?.code).toBe("CONFLICT");
expect(res.errors?.[0].extensions?.field).toBe("email");
expect(JSON.stringify(res)).not.toContain("users_email_key");
});
Enter fullscreen mode Exit fullscreen mode

I'm packaging patterns like this as Claude Code skills for TypeScript + GraphQL + Postgres backends, all tested against a real backend in CI. Two are free on GitHub.

Top comments (2)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Signing up twice with one email as a leak test is a handy check, and the stable error code idea makes the client side much calmer.

Collapse
 
mujtbabbas profile image
Syed Mujtaba •

The SQLSTATE translation at the data layer is the part I would underline. On booking platforms this pulls double duty: the same 23505 translation is what makes Stripe webhook handlers idempotent, because a retried webhook and a duplicate form submit land on the same unique constraint. One thing I would add: log the masked error id at the translation point too, not only in formatError. When support quotes an error id from the client, it helps to grep it in one place even if the formatError path changed later.