RizTech Academy logo
RizTech Academy
Engineering Practices Across a StackLesson 3 of 535 min

Contracts, error shapes and changing them safely

An API is a promise. Somebody wrote code against the shape you return, and that code is now running on a phone you cannot update. The contract is not the documentation — it is what the clients actually depend on, which is usually more than you documented.

Errors need a shape, and it must be the same shape

The commonest failure is an API that fails differently every time:

{ "message": "Not found" }
{ "error": "insufficient stock" }
{ "errors": [{ "field": "quantity", "msg": "too big" }] }
"Something went wrong"

A client cannot handle that. It ends up matching on English strings, and then a reworded message breaks the front end.

The Kirana Store has one shape:

export interface ApiErrorBody {
  statusCode: number;
  code: ErrorCode;
  message: string;
  details?: Record<string, unknown>;
}

And the codes live in the shared package:

export const ErrorCode = {
  VALIDATION_FAILED: "VALIDATION_FAILED",
  UNAUTHENTICATED: "UNAUTHENTICATED",
  FORBIDDEN: "FORBIDDEN",
  NOT_FOUND: "NOT_FOUND",
  INSUFFICIENT_STOCK: "INSUFFICIENT_STOCK",
  PRICE_CHANGED: "PRICE_CHANGED",
  SLOT_FULL: "SLOT_FULL",
  …
} as const;

The docblock on that file states the reason in one sentence: messages get reworded and translated; these do not, so a client can switch on them safely.

switch (error.code) {
  case ErrorCode.INSUFFICIENT_STOCK:
    return <p>Only {error.details?.available} left. Reduce the quantity?</p>;
  case ErrorCode.PRICE_CHANGED:
    return <p>The price changed while you were shopping. Review your cart.</p>;
  default:
    return <p>{error.message}</p>;
}

Two things that follow. The code is for the program; the message is for the person. And a default branch, because the API will add codes that this build of the client has never heard of.

details is where the machine-readable specifics go — which field, how much stock is left. Not in the message, where the client would have to parse English to recover them.

Status codes, and the two that get chosen wrongly

Most of the table is uncontroversial: 200 worked, 201 created, 400 malformed, 401 not signed in, 403 signed in but not allowed, 404 not there, 500 our fault.

Two are worth arguing about.

409 Conflict, not 400, when the request was well-formed but the world is in the wrong state. "Your cart is empty", "this order cannot be cancelled now", "that slot filled up". Nothing is wrong with the request — retrying the same request after fixing the state would work, and that is a different thing for a client to do than fixing the request.

if (order.status !== "PENDING_PAYMENT") {
  throw new AppException(
    ErrorCode.VALIDATION_FAILED,
    HttpStatus.CONFLICT,
    "That order is not waiting for payment.",
    { status: order.status },
  );
}

401 versus 403. 401 means "I do not know who you are" — the client should send you to sign in. 403 means "I know exactly who you are, and no" — signing in again achieves nothing. Get these the wrong way round and you build a redirect loop that logs a user out, back in, and straight back to the same 401.

And one deliberate 200: a webhook for something you have never heard of.

if (!payment) {
  this.logger.warn(`Webhook for unknown payment ${event.providerRef}`);
  return { received: true };
}

404 would be correct and would also be wrong, because a gateway that receives an error retries — and retrying will not make you recognise it. You would be signing up for that request every few minutes forever.

What an error must not contain

An error is a response to somebody you have not met.

// leaks the schema, the table name, and that the row exists
throw new Error(`update "orders" set status=$1 where id=$2 failed: ${err.message}`);
// 401, and no detail. A forged request should learn nothing about why it
// was rejected.
throw new AppException(ErrorCode.UNAUTHENTICATED, HttpStatus.UNAUTHORIZED, "Bad signature.");

Log the detail server-side with everything you need; return the least the client can act on. Stack traces, SQL, internal ids, upstream provider messages and library errors all stay on your side.

The subtler version is leaking existence. "No such user" on sign-in tells an attacker which email addresses have accounts. "No such order" for an order that exists but belongs to somebody else tells them the order number is real. Both should be the same response as genuinely-not-found — which is exactly what where: { orderNumber, userId } gives you for free.

Write the contract down in types both sides import

// packages/shared/src/order.ts
export interface OrderSummary { … }
export interface CreateOrderRequest { … }

The API returns OrderSummary; the web app's fetch is typed as OrderSummary. Remove a field and both fail to compile. The typecheck is the contract test, and it costs nothing to run.

That is not the same as a real contract test — TypeScript disappears at runtime, so it cannot tell you the API actually sends what it claims. That is what the module 15 API tests are for. But it catches the whole class of "we renamed a field and forgot the other app", which is the mistake that actually happens.

Changing a contract

Adding an optional field is safe. Adding a required field to a request is not. Renaming anything is not. Removing anything is not. Changing a type — number to string, or a single object to an array — is not, and is the one people underestimate.

The sequence for anything unsafe, and it is the same sequence every time:

  1. Add the new thing alongside the old. Both work.
  2. Move the clients over.
  3. Wait — for a deploy, for old tabs to close, for a mobile release to reach people.
  4. Delete the old thing.

Never steps 1 and 4 in one deploy. Even with one web app, the old bundle is still running in somebody's open tab while the new API is live. That tab is a client you cannot update, and it is the reason "we control both sides" is not the exemption it sounds like.

If a change is genuinely incompatible and cannot be staged, version the endpoint — /v2/orders — and keep /v1 until nothing calls it. Versioning is a cost, so prefer additive changes; but a version is cheaper than a broken checkout.

The database has the same problem, worse. A migration that drops a column runs before the new code is everywhere, and the old code is still selecting it. Expand, migrate, contract: add the column, write to both, backfill, move reads over, then drop — in separate deploys.

Idempotency

Networks retry. Customers double-tap. Gateways deliver the same webhook twice as documented behaviour, not as a fault.

// Idempotency. Gateways deliver more than once — that is normal, documented
// behaviour, not a fault — so the second delivery must change nothing.
if (payment.status !== "PENDING") {
  this.logger.log(`Ignoring repeat webhook for ${event.providerRef}`);
  return { received: true };
}

"The second identical request changes nothing" is a property you design in, not one you get. The Kirana Store gets it three ways: the status check above, the unique providerRef, and reusing an open payment instead of opening a second one when somebody refreshes the payment page.

For anything that costs money or creates a record, take an idempotency key from the client and store it. Then a retry can be recognised as a retry rather than becoming a second order.

Check your work

Why one error shape: a client cannot handle four, so it matches on English and breaks when you reword.

Code versus message: the code is for the program and never changes; the message is for the person and will.

Why a default branch: the API will add codes this client has never heard of.

When 409 rather than 400: the request was fine, the state was not.

401 versus 403: unknown versus known-and-refused — get it wrong and you build a redirect loop.

Why 200 for an unknown webhook: an error makes the gateway retry forever.

What must stay server-side: stack traces, SQL, internal ids — and the fact that a record exists.

What the typecheck gives you: the rename class of bug, for free; not proof the API sends what it claims.

Which changes are safe: adding optional things. Everything else is expand, migrate, contract.

Why "we control both sides" is not an exemption: the old bundle is still running in an open tab.

What idempotency is: a property you design in, so a repeat changes nothing.

Practice

  1. List every error shape your API can return. If there is more than one, unify them.
  2. Add a new ErrorCode and handle it in the front end. Confirm an unknown code falls to the default.
  3. Find an error returning 400 where the request was well-formed. Change it to 409.
  4. Find a 401 that should be 403, or the reverse. Follow what the client does with each.
  5. Trigger a database error and read exactly what the client receives.
  6. Ask for an order that exists but belongs to another user. Confirm the response cannot be told from not-found.
  7. Remove a field from a shared type and count the compile errors in both apps.
  8. Add a required field to a request DTO and call the endpoint with the old payload. That is what an open tab does.
  9. Write out the expand–migrate–contract steps for renaming one column in your schema.
  10. Send the same webhook twice and confirm the second changes nothing. Then send it with a different amount and confirm it is refused.

Next: the bugs that live in the gaps between layers.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship