DEV Community

kevindev
kevindev

Posted on

Request IDs Are Not Idempotency Keys

Why the two identifiers get confused

Most production APIs eventually need two different answers:

  1. Which incoming request produced this log line?
  2. Which client intention should be applied only once?

Teams often answer both questions with one header. That works until a client retries after a timeout. The server may receive the same business operation twice, while the two network attempts deserve two separate traces. A request ID is about observability. An idempotency key is about business behavior.

It sounds like a small naming issue, but it become a data-integrity issue as soon as an endpoint creates a payment, account, job, or email-verification record.

What a request ID should do

A request ID identifies one attempt through the system. The edge proxy can generate one when the caller does not provide it, and every downstream service should propagate it. It belongs in:

  • access logs and structured application logs
  • trace attributes
  • error responses and support tickets
  • messages emitted for that attempt

The value should be safe to log and should have a bounded size. Do not use it as permission to replay a command, and do not assume that two attempts with the same request ID are the same business operation. Some clients incorrectly reuse it when retrying, and some clients generate a new one for every attempt.

In Node.js, put the value in request-scoped context. A middleware can read X-Request-ID, validate its length and character set, or generate a replacement. The handler then uses that context for logs without passing an observability concern through every function signature.

What an idempotency key should do

An idempotency key represents a client operation within a defined scope. For example, a client can send Idempotency-Key: checkout-7f2... when creating an order. If the request times out, it retries with the same key. The API should return the original result, or a clear conflict if the same key is reused with a different payload.

That requires a contract, not just a header:

  • Scope: tenant, user, endpoint, or another explicit boundary.
  • Payload binding: store a hash of the relevant input and reject mismatches.
  • Result behavior: replay the status and response body when possible.
  • Retention: define how long a key remains valid.
  • Concurrency: decide what a second request does while the first is still running.

Retries gets easier to reason about when the key follows the business operation, while the request ID follows the network attempt.

Put the guarantee in PostgreSQL

An in-memory map is useful for a local prototype, but it cannot protect a horizontally scaled service. The durable uniqueness rule belongs in PostgreSQL:

CREATE TABLE api_idempotency_keys (
  tenant_id       uuid        NOT NULL,
  idempotency_key text        NOT NULL,
  request_hash    text        NOT NULL,
  status_code     integer,
  response_body   jsonb,
  created_at      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, idempotency_key)
);
Enter fullscreen mode Exit fullscreen mode

The primary key prevents two workers from successfully claiming the same operation. The application should insert the key and create the business record in one transaction, or use a state column such as processing, completed, and failed when the work spans an asynchronous queue.

Do not save only the final response. A crash after the business row commits but before the response is stored is a real failure mode. The recovery policy might read the business record and reconstruct the response, or mark the operation for safe reconciliation. The important part is that the policy is explicit.

A Node.js request flow

A practical REST API flow looks like this:

  1. Middleware establishes the request ID and logging context.
  2. The handler requires an idempotency key for non-safe mutations.
  3. The service computes a canonical payload hash.
  4. PostgreSQL attempts to claim (tenant_id, idempotency_key).
  5. An existing row with the same hash replays its result.
  6. An existing row with a different hash returns 409 Conflict.
  7. A new row and the business mutation commit together.

The API should never silently turn a changed payload into the old result. That hides client bugs and can apply a response to the wrong intent. It also make incident review much harder.

The same boundary is useful in email and signup fixtures. User-entered strings such as tem email, temp gamil com, or a search phrase like tp mail so are inputs to validate; they are not stable operation identifiers. Keep them in test data and analytics, never in authorization or deduplication decisions.

For related thinking on isolating test-side inbox behavior, see a better inbox contract for CI and preventing cross-test inbox pollution.

Testing retries without fooling yourself

Unit tests are not enough. Add an integration test that sends the same mutation twice, concurrently if possible, and asserts that only one business row exists. Then force these boundaries:

  • client timeout after the database commit
  • worker crash before response persistence
  • duplicate requests arriving on different instances
  • same key with a changed payload
  • key reuse after the retention window

Some test cases is especially valuable when the response contains generated values, such as an order ID. The second response should match the first contract, not generate a new order while returning a superficially successful status.

Log both identifiers on every mutation. A useful event has request_id, idempotency_key, tenant scope, operation state, and database outcome. Redact sensitive payload fields and avoid logging full authorization headers. When debugging, you can follow one request ID across retries, then group those attempts by idempotency key to understand the business result.

Operational checklist

Before shipping a retry-safe mutation, verify:

  • The API documentation explains both headers and their different lifetimes.
  • The idempotency scope is part of the database key.
  • Payload mismatches fail loudly.
  • The uniqueness constraint is enforced by PostgreSQL, not only application code.
  • Concurrent claims have a defined response.
  • Recovery after a partial failure is documented and tested.
  • Metrics distinguish new operations, replays, conflicts, and expired keys.
  • Logs let an engineer find one attempt and then all attempts for an operation quick.

Request IDs help you see what happened. Idempotency keys help ensure it happened once. Keeping those guarantees separate makes the REST API easier to operate, the PostgreSQL model easier to audit, and retry behavior less surprising for every client.

Top comments (0)