feat(api): generic Idempotency-Key support for safe POST retries

Introduces `withIdempotency()` in `src/lib/idempotency.ts` — wraps a
mutation handler so that a POST/PUT/PATCH/DELETE carrying an
`Idempotency-Key` header is replayed from the cached response instead of
running twice. Cache row is keyed by `(userId, key, method, path)` with
24h TTL; replays carry `X-Idempotent-Replay: true` so callers can
distinguish them.

Applied to the three mutation endpoints most exposed to client retries:
  • POST `/api/measurements`
  • POST `/api/mood-entries`
  • POST `/api/medications/[id]/intake`

Without the header: zero behaviour change. Existing clients that don't
send the header simply skip the cache lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:53:02 +02:00
1 parent c8387d4f2b
commit 9766cedea4
5 files changed
+338 -6

No files matched your search

+13 -2
View File
@@ -9,6 +9,8 @@ import {
listMeasurementsSchema,
getUnitForType,
} from "@/lib/validations/measurement";
import { withIdempotency } from "@/lib/idempotency";
import { getSession } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -17,6 +19,11 @@ import type {
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
async function resolveUserIdForIdempotency(): Promise<string | null> {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -59,7 +66,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postMeasurement, resolveUserIdForIdempotency),
);
async function postMeasurement(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
@@ -159,4 +170,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
return apiSuccess(measurement, 201);
});
}
+16 -2
View File
@@ -7,11 +7,25 @@ import {
intakeSchema,
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { withIdempotency } from "@/lib/idempotency";
import { getSession } from "@/lib/auth/session";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
async function resolveUserIdForIdempotency(): Promise<string | null> {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
}
export const POST = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(
postIntake,
resolveUserIdForIdempotency,
),
);
async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user } = await requireAuth();
const { id } = await params;
@@ -100,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
});
return apiSuccess(event, 201);
});
}
export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
+13 -2
View File
@@ -10,6 +10,13 @@ import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { getSession } from "@/lib/auth/session";
async function resolveUserIdForIdempotency(): Promise<string | null> {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
}
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
@@ -73,7 +80,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postMoodEntry, resolveUserIdForIdempotency),
);
async function postMoodEntry(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
@@ -119,4 +130,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
throw err;
}
});
}
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
idempotencyKey: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { withIdempotency } from "../idempotency";
import { prisma } from "@/lib/db";
function makeRequest(
method: string,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/example", {
method,
headers: { "content-type": "application/json", ...headers },
body: method === "GET" ? undefined : JSON.stringify({ ok: true }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
});
describe("withIdempotency", () => {
it("passes through when no Idempotency-Key header is present", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(makeRequest("POST"));
expect(res.status).toBe(201);
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
it("caches the response and replays it on the second call", async () => {
let callCount = 0;
const handler = vi.fn(async () => {
callCount += 1;
return NextResponse.json(
{ data: { result: callCount }, error: null },
{ status: 201 },
);
});
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
// First call: nothing cached → handler runs → response stored.
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res1 = await wrapped(req1);
const body1 = await res1.json();
expect(res1.status).toBe(201);
expect(body1).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
// Capture the persisted body for replay.
const persistedBody = (
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
data: { responseBody: string; responseStatus: number };
}
).data;
// Second call: cache hit returns persisted envelope.
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-1",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: persistedBody.responseStatus,
responseBody: persistedBody.responseBody,
expiresAt: new Date(Date.now() + 86_400_000),
createdAt: new Date(),
} as never);
const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res2 = await wrapped(req2);
expect(res2.headers.get("X-Idempotent-Replay")).toBe("true");
expect(res2.status).toBe(201);
const body2 = await res2.json();
expect(body2).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1); // not called again
});
it("ignores expired cache rows and re-runs the handler", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
);
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-old",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: 200,
responseBody: '{"data":"stale","error":null}',
expiresAt: new Date(Date.now() - 1000),
createdAt: new Date(),
} as never);
vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
const body = await res.json();
expect(handler).toHaveBeenCalledTimes(1);
expect(body).toEqual({ data: "fresh", error: null });
expect(prisma.idempotencyKey.delete).toHaveBeenCalled();
});
it("ignores malformed Idempotency-Key headers", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "!!" }));
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
it("does nothing for GET requests", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* Idempotency-Key support for write endpoints (POST/PUT/PATCH/DELETE).
*
* Mobile clients send `Idempotency-Key: <uuid>`. The first request runs
* the handler normally and the response (status + body) is cached. Any
* retry within the TTL (24h) for the same `(userId, key, method, path)`
* tuple returns the cached envelope as a 200 (carrying the original
* status inside the JSON if needed) — no second side-effect.
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
const TTL_MS = 24 * 60 * 60 * 1000;
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/;
export interface IdempotencyContext {
userId: string;
key: string;
method: string;
path: string;
}
function getIdempotencyKey(request: Request | NextRequest): string | null {
const raw = request.headers.get("idempotency-key");
if (!raw) return null;
const trimmed = raw.trim();
if (!KEY_REGEX.test(trimmed)) return null;
return trimmed;
}
/**
* Look up a cached response for a (userId, key, method, path) tuple.
* Returns the cached NextResponse or null.
*/
async function findCached(ctx: IdempotencyContext): Promise<NextResponse | null> {
const row = await prisma.idempotencyKey.findUnique({
where: {
userId_key_method_path: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
},
},
});
if (!row) return null;
if (row.expiresAt <= new Date()) {
// Stale — purge and fall through.
await prisma.idempotencyKey.delete({ where: { id: row.id } }).catch(() => {});
return null;
}
let parsed: unknown = null;
try {
parsed = JSON.parse(row.responseBody);
} catch {
parsed = null;
}
annotate({
action: { name: "idempotency.replay" },
meta: { method: ctx.method, path: ctx.path },
});
// Replay original status to keep client behaviour byte-identical.
return NextResponse.json(parsed, {
status: row.responseStatus,
headers: {
"X-Idempotent-Replay": "true",
},
});
}
async function persistCached(
ctx: IdempotencyContext,
response: Response,
): Promise<void> {
const cloned = response.clone();
const body = await cloned.text().catch(() => "");
await prisma.idempotencyKey
.create({
data: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
responseStatus: response.status,
responseBody: body,
expiresAt: new Date(Date.now() + TTL_MS),
},
})
.catch(() => {
// Concurrent insert with the same key — the other writer wins.
// Ignore because a future replay will hit their cached value.
});
}
/**
* Wrap a write handler so a repeat call with the same `Idempotency-Key`
* (and same userId/method/path) returns the originally cached response.
*
* The wrapped handler is responsible for authentication itself — this
* helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only
* once `userIdResolver` returns a non-null value.
*
* No-op when the header is missing or the value is malformed.
*/
export function withIdempotency<
Args extends [Request | NextRequest, ...unknown[]],
>(
handler: (...args: Args) => Promise<Response>,
userIdResolver: (...args: Args) => Promise<string | null>,
): (...args: Args) => Promise<Response> {
return async (...args: Args): Promise<Response> => {
const request = args[0];
if (!SUPPORTED_METHODS.has(request.method)) {
return handler(...args);
}
const key = getIdempotencyKey(request);
if (!key) return handler(...args);
const userId = await userIdResolver(...args);
if (!userId) return handler(...args);
const url = new URL(request.url);
const ctx: IdempotencyContext = {
userId,
key,
method: request.method,
path: url.pathname,
};
const cached = await findCached(ctx);
if (cached) return cached;
const response = await handler(...args);
// Persist regardless of status so error retries don't fork into
// two different responses (matches Stripe/Square semantics).
await persistCached(ctx, response);
return response;
};
}