mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
c8387d4f2b
commit
9766cedea4
5 files changed
+338
-6
No files matched your search
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user