| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { runRedisPipeline } from './redis'; |
|
|
| |
| export const IDEMPOTENCY_HEADER = 'Idempotency-Key'; |
| |
| export const IDEMPOTENT_REPLAYED_HEADER = 'Idempotent-Replayed'; |
|
|
| |
| |
| |
| |
| const KEY_MAX_LENGTH = 255; |
| const KEY_PATTERN = /^[\x21-\x7e]{1,255}$/; |
|
|
| |
| |
| |
| |
| const PROCESSING_TTL_SECONDS = 180; |
| |
| const COMPLETED_TTL_SECONDS = 24 * 60 * 60; |
| |
| |
| |
| const MAX_STORED_BODY_BYTES = 256 * 1024; |
|
|
| const PROCESSING_MARKER = JSON.stringify({ state: 'processing' }); |
|
|
| |
| |
| |
| |
| |
| interface CompletedRecord { |
| state: 'completed'; |
| status: number; |
| contentType: string | null; |
| reqHash: string; |
| body: string; |
| } |
|
|
| |
| |
| |
| |
| |
| export type IdempotencyOutcome = |
| | { kind: 'disabled' } |
| | { kind: 'invalid'; response: Response } |
| | { kind: 'replay'; response: Response } |
| | { kind: 'conflict'; response: Response } |
| | { kind: 'mismatch'; response: Response } |
| | { |
| kind: 'proceed'; |
| key: string; |
| |
| |
| |
| |
| store: (status: number, body: ArrayBuffer, contentType: string | null) => Promise<void>; |
| }; |
|
|
| type IdempotencyTerminalOutcome = Exclude<IdempotencyOutcome, { kind: 'proceed' }>; |
|
|
| export type IdempotencyPeekOutcome = IdempotencyTerminalOutcome | { kind: 'miss' }; |
|
|
| export interface BeginIdempotencyArgs { |
| |
| request: Request; |
| |
| pathname: string; |
| |
| scope: string | null; |
| |
| idempotencyKey: string; |
| |
| corsHeaders: Record<string, string>; |
| } |
|
|
| export function isValidIdempotencyKey(key: string): boolean { |
| return key.length <= KEY_MAX_LENGTH && KEY_PATTERN.test(key); |
| } |
|
|
| export const IDEMPOTENCY_KEY_PATTERN = '^[\\x21-\\x7e]{1,255}$'; |
|
|
| async function sha256Hex(input: string | ArrayBuffer): Promise<string> { |
| const data = typeof input === 'string' ? new TextEncoder().encode(input) : input; |
| const digest = await crypto.subtle.digest('SHA-256', data); |
| return Array.from(new Uint8Array(digest)) |
| .map((b) => b.toString(16).padStart(2, '0')) |
| .join(''); |
| } |
|
|
| function isReplayableTextBody(contentType: string | null): boolean { |
| if (!contentType) return false; |
| const ct = contentType.toLowerCase(); |
| |
| |
| |
| return ct.includes('json') || ct.startsWith('text/'); |
| } |
|
|
| function isRetryableStatus(status: number): boolean { |
| return status === 408 || status === 409 || status === 429 || status >= 500; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function anonScope(request: Request): string { |
| const ip = |
| request.headers.get('cf-connecting-ip') || |
| request.headers.get('x-real-ip') || |
| (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() || |
| 'unknown'; |
| return `ip:${ip}`; |
| } |
|
|
| function jsonResponse( |
| status: number, |
| body: Record<string, unknown>, |
| corsHeaders: Record<string, string>, |
| extraHeaders: Record<string, string> = {}, |
| ): Response { |
| return new Response(JSON.stringify(body), { |
| status, |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Cache-Control': 'no-store', |
| ...corsHeaders, |
| ...extraHeaders, |
| }, |
| }); |
| } |
|
|
| async function getRequestHashAndRedisKey( |
| request: Request, |
| pathname: string, |
| scope: string | null, |
| idempotencyKey: string, |
| ): Promise<{ reqHash: string; redisKey: string } | null> { |
| try { |
| const bodyBuf = await request.clone().arrayBuffer(); |
| const reqHash = await sha256Hex(bodyBuf); |
| const effectiveScope = scope || anonScope(request); |
| |
| |
| const redisKey = `idem:v1:${await sha256Hex(`${effectiveScope}\n${pathname}\n${idempotencyKey}`)}`; |
| return { reqHash, redisKey }; |
| } catch { |
| |
| return null; |
| } |
| } |
|
|
| function outcomeFromStoredRecord( |
| raw: unknown, |
| reqHash: string, |
| idempotencyKey: string, |
| corsHeaders: Record<string, string>, |
| ): IdempotencyPeekOutcome { |
| if (raw == null) return { kind: 'miss' }; |
|
|
| let record: CompletedRecord | { state: 'processing' } | null = null; |
| if (typeof raw === 'string') { |
| try { |
| record = JSON.parse(raw); |
| } catch { |
| record = null; |
| } |
| } |
|
|
| if (!record) { |
| |
| return { kind: 'disabled' }; |
| } |
|
|
| if (record.state === 'processing') { |
| return { |
| kind: 'conflict', |
| response: jsonResponse( |
| 409, |
| { |
| error: 'idempotency_conflict', |
| message: `A request with this ${IDEMPOTENCY_HEADER} is still being processed. Retry shortly.`, |
| }, |
| corsHeaders, |
| { 'Retry-After': '2', [IDEMPOTENCY_HEADER]: idempotencyKey }, |
| ), |
| }; |
| } |
|
|
| if (record.reqHash !== reqHash) { |
| return { |
| kind: 'mismatch', |
| response: jsonResponse( |
| 422, |
| { |
| error: 'idempotency_key_reused', |
| message: `This ${IDEMPOTENCY_HEADER} was already used with a different request body.`, |
| }, |
| corsHeaders, |
| { [IDEMPOTENCY_HEADER]: idempotencyKey }, |
| ), |
| }; |
| } |
|
|
| |
| return { |
| kind: 'replay', |
| response: new Response(record.body, { |
| status: record.status, |
| headers: { |
| 'Content-Type': record.contentType ?? 'application/json', |
| 'Cache-Control': 'no-store', |
| ...corsHeaders, |
| [IDEMPOTENCY_HEADER]: idempotencyKey, |
| [IDEMPOTENT_REPLAYED_HEADER]: 'true', |
| }, |
| }), |
| }; |
| } |
|
|
| function isPipelineSuccess(entry: { result?: unknown; error?: unknown } | undefined, expected: unknown): boolean { |
| return entry?.error == null && entry?.result === expected; |
| } |
|
|
| async function releaseProcessingLock(redisKey: string): Promise<void> { |
| await runRedisPipeline([['DEL', redisKey]]); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function peekIdempotency(args: BeginIdempotencyArgs): Promise<IdempotencyPeekOutcome> { |
| const { request, pathname, scope, idempotencyKey, corsHeaders } = args; |
|
|
| if (!isValidIdempotencyKey(idempotencyKey)) { |
| return { |
| kind: 'invalid', |
| response: jsonResponse( |
| 400, |
| { |
| error: 'invalid_idempotency_key', |
| message: `The ${IDEMPOTENCY_HEADER} header must be 1-${KEY_MAX_LENGTH} printable ASCII characters.`, |
| }, |
| corsHeaders, |
| ), |
| }; |
| } |
|
|
| const resolved = await getRequestHashAndRedisKey(request, pathname, scope, idempotencyKey); |
| if (!resolved) return { kind: 'disabled' }; |
|
|
| const pipeline = await runRedisPipeline([['GET', resolved.redisKey]]); |
| if (pipeline.length < 1) return { kind: 'disabled' }; |
|
|
| const entry = pipeline[0] as { result?: unknown; error?: unknown } | undefined; |
| if (entry?.error) return { kind: 'disabled' }; |
|
|
| return outcomeFromStoredRecord(entry?.result, resolved.reqHash, idempotencyKey, corsHeaders); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function beginIdempotency(args: BeginIdempotencyArgs): Promise<IdempotencyOutcome> { |
| const { request, pathname, scope, idempotencyKey, corsHeaders } = args; |
|
|
| if (!isValidIdempotencyKey(idempotencyKey)) { |
| return { |
| kind: 'invalid', |
| response: jsonResponse( |
| 400, |
| { |
| error: 'invalid_idempotency_key', |
| message: `The ${IDEMPOTENCY_HEADER} header must be 1-${KEY_MAX_LENGTH} printable ASCII characters.`, |
| }, |
| corsHeaders, |
| ), |
| }; |
| } |
|
|
| const resolved = await getRequestHashAndRedisKey(request, pathname, scope, idempotencyKey); |
| if (!resolved) return { kind: 'disabled' }; |
|
|
| |
| |
| |
| const pipeline = await runRedisPipeline([ |
| ['SET', resolved.redisKey, PROCESSING_MARKER, 'NX', 'EX', String(PROCESSING_TTL_SECONDS)], |
| ['GET', resolved.redisKey], |
| ]); |
|
|
| |
| if (pipeline.length < 2) return { kind: 'disabled' }; |
|
|
| |
| |
| const claim = pipeline[0] as { result?: unknown; error?: unknown } | undefined; |
| if (claim?.error) return { kind: 'disabled' }; |
|
|
| const claimed = claim?.result === 'OK'; |
| if (claimed) { |
| return { |
| kind: 'proceed', |
| key: idempotencyKey, |
| store: (status, body, contentType) => |
| storeResult(resolved.redisKey, status, body, contentType, resolved.reqHash), |
| }; |
| } |
|
|
| |
| const raw = pipeline[1]?.result; |
| const outcome = outcomeFromStoredRecord(raw, resolved.reqHash, idempotencyKey, corsHeaders); |
| return outcome.kind === 'miss' ? { kind: 'disabled' } : outcome; |
| } |
|
|
| async function storeResult( |
| redisKey: string, |
| status: number, |
| body: ArrayBuffer, |
| contentType: string | null, |
| reqHash: string, |
| ): Promise<void> { |
| try { |
| |
| |
| |
| if ( |
| isRetryableStatus(status) || |
| body.byteLength > MAX_STORED_BODY_BYTES || |
| !isReplayableTextBody(contentType) |
| ) { |
| await releaseProcessingLock(redisKey); |
| return; |
| } |
| const record: CompletedRecord = { |
| state: 'completed', |
| status, |
| contentType, |
| reqHash, |
| body: new TextDecoder().decode(body), |
| }; |
| const pipeline = await runRedisPipeline([ |
| ['SET', redisKey, JSON.stringify(record), 'EX', String(COMPLETED_TTL_SECONDS)], |
| ]); |
| if (!isPipelineSuccess(pipeline[0] as { result?: unknown; error?: unknown } | undefined, 'OK')) { |
| await releaseProcessingLock(redisKey); |
| } |
| } catch { |
| |
| try { |
| await releaseProcessingLock(redisKey); |
| } catch { |
| |
| } |
| } |
| } |
|
|