Contracts and security

Handle retries and abuse

Use request limits, idempotency, and safe timeouts to make the API predictable under duplicate traffic and deliberate pressure.

Networks drop packets and clients retry. Users double-click a button. None of that is malicious, and all of it means your API receives the same request twice. Then there are the people who send ten thousand requests a second on purpose. You need a policy for both.

Cheap limits first

The cheapest defense runs before any real work. Hono has middleware for the two most useful limits:

import { bodyLimit } from 'hono/body-limit'
import { timeout } from 'hono/timeout'

app.use('/books/*', bodyLimit({ maxSize: 64 * 1024 }))
app.use('/books/*', timeout(5000))

The first rejects any body over 64 KB with a 413 before parsing it. A book has three fields; nobody needs more. The second gives up after five seconds with a 504, so a stuck database call doesn’t pin a connection forever. Also pass c.req.raw.signal, an AbortSignal that fires when the client disconnects, to fetch() calls and long operations.

Rate limits and the identity problem

A rate limit caps how many requests one caller can make in a window. Over the cap, you answer 429 Too Many Requests with a Retry-After header saying how long to wait.

The hard part is the word “caller”. The obvious choice is the IP address, and it’s often wrong. A whole office shares one IP behind NAT, and if you read the IP from X-Forwarded-For, anyone can send that header and pick their own identity. Prefer the authenticated user from the previous lesson. Fall back to an IP only when a proxy you trust sets it.

Idempotency keys

Now the honest duplicates. POST /books is not idempotent, so a retry creates two books. The fix is a convention payment APIs use: the client sends an idempotency key, a random string that identifies this attempt.

curl -i --json '{"title":"Dune","author":"Frank Herbert"}' \
  -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7' \
  http://localhost:3000/books

The server stores the key with the result. When the same key arrives again, it returns the stored 201 and Location instead of creating another book. Send that curl twice: the responses match, and GET /books still shows one Dune.

The record needs a few fields:

CREATE TABLE idempotency_keys (
  key TEXT NOT NULL,
  user_id TEXT NOT NULL,
  request_hash TEXT NOT NULL,
  state TEXT NOT NULL,
  response_status INTEGER,
  response_body TEXT,
  created_at TEXT NOT NULL,
  PRIMARY KEY (key, user_id)
);

Scope the key to the user, so two users can’t collide. Store a hash of the request body, and if the same key arrives with a different body, answer 422: a reused key with new input is a client bug, not a retry. The primary key handles two requests arriving at the same instant: one insert fails, and that request either waits or answers 409 Conflict.

Delete keys older than 24 hours. And decide what happens to a record stuck in processing after a crash: expire it after a minute, or the caller is blocked for good.

Design the idempotency table and a small per-user limit now. Then test three cases: the same request twice in a row, twice at once with curl &, and the same key with a different title.

Lesson completed