Skip to content

Commit 31e0e7c

Browse files
authored
fix(api): add standalone write idempotency (koala73#4792)
1 parent c2a531d commit 31e0e7c

9 files changed

Lines changed: 928 additions & 67 deletions

api/_idempotency.d.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export const IDEMPOTENCY_HEADER: 'Idempotency-Key';
2+
export const IDEMPOTENT_REPLAYED_HEADER: 'Idempotent-Replayed';
3+
4+
export type StandaloneIdempotencyTerminal =
5+
| { kind: 'disabled' }
6+
| { kind: 'invalid'; response: Response }
7+
| { kind: 'replay'; response: Response }
8+
| { kind: 'conflict'; response: Response }
9+
| { kind: 'mismatch'; response: Response };
10+
11+
export type StandaloneIdempotencyOutcome =
12+
| StandaloneIdempotencyTerminal
13+
| {
14+
kind: 'proceed';
15+
key: string;
16+
store: (status: number, body: ArrayBuffer, contentType: string | null) => Promise<void>;
17+
};
18+
19+
export type StandaloneIdempotencyPeekOutcome =
20+
| StandaloneIdempotencyTerminal
21+
| { kind: 'miss' };
22+
23+
export function isValidIdempotencyKey(key: string): boolean;
24+
25+
export function getIdempotencyKey(request: Request): string | null;
26+
27+
export function peekStandaloneIdempotency(args: {
28+
request: Request;
29+
pathname: string;
30+
scope: string | null;
31+
idempotencyKey: string;
32+
corsHeaders: Record<string, string>;
33+
}): Promise<StandaloneIdempotencyPeekOutcome>;
34+
35+
export function beginStandaloneIdempotency(args: {
36+
request: Request;
37+
pathname: string;
38+
scope: string | null;
39+
idempotencyKey: string;
40+
corsHeaders: Record<string, string>;
41+
completedTtlSeconds?: number;
42+
}): Promise<StandaloneIdempotencyOutcome>;
43+
44+
export function completeStandaloneIdempotency(
45+
idempotency: StandaloneIdempotencyOutcome | null,
46+
response: Response,
47+
): Promise<Response>;

api/_idempotency.js

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { redisPipeline } from './_upstash-json.js';
2+
3+
export const IDEMPOTENCY_HEADER = 'Idempotency-Key';
4+
export const IDEMPOTENT_REPLAYED_HEADER = 'Idempotent-Replayed';
5+
6+
const KEY_MAX_LENGTH = 255;
7+
const KEY_PATTERN = /^[\x21-\x7e]{1,255}$/;
8+
const PROCESSING_TTL_SECONDS = 180;
9+
const DEFAULT_COMPLETED_TTL_SECONDS = 24 * 60 * 60;
10+
const MAX_STORED_BODY_BYTES = 256 * 1024;
11+
const PROCESSING_MARKER = JSON.stringify({ state: 'processing' });
12+
13+
export function isValidIdempotencyKey(key) {
14+
return typeof key === 'string' && key.length <= KEY_MAX_LENGTH && KEY_PATTERN.test(key);
15+
}
16+
17+
async function sha256Hex(input) {
18+
const data = typeof input === 'string' ? new TextEncoder().encode(input) : input;
19+
const digest = await crypto.subtle.digest('SHA-256', data);
20+
return Array.from(new Uint8Array(digest))
21+
.map((b) => b.toString(16).padStart(2, '0'))
22+
.join('');
23+
}
24+
25+
function anonScope(request) {
26+
const ip =
27+
request.headers.get('cf-connecting-ip') ||
28+
request.headers.get('x-real-ip') ||
29+
(request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() ||
30+
'unknown';
31+
return `ip:${ip}`;
32+
}
33+
34+
function jsonResponse(status, body, corsHeaders, extraHeaders = {}) {
35+
return new Response(JSON.stringify(body), {
36+
status,
37+
headers: {
38+
'Content-Type': 'application/json',
39+
'Cache-Control': 'no-store',
40+
...corsHeaders,
41+
...extraHeaders,
42+
},
43+
});
44+
}
45+
46+
function isReplayableTextBody(contentType) {
47+
if (!contentType) return false;
48+
const ct = contentType.toLowerCase();
49+
return ct.includes('json') || ct.startsWith('text/');
50+
}
51+
52+
function isRetryableStatus(status) {
53+
return status === 408 || status === 409 || status === 429 || status >= 500;
54+
}
55+
56+
async function getRequestHashAndRedisKey(request, pathname, scope, idempotencyKey) {
57+
try {
58+
const bodyBuf = await request.clone().arrayBuffer();
59+
const reqHash = await sha256Hex(bodyBuf);
60+
const effectiveScope = scope || anonScope(request);
61+
const redisKey = `idem:v1:${await sha256Hex(`${effectiveScope}\n${pathname}\n${idempotencyKey}`)}`;
62+
return { reqHash, redisKey };
63+
} catch {
64+
return null;
65+
}
66+
}
67+
68+
function outcomeFromStoredRecord(raw, reqHash, idempotencyKey, corsHeaders) {
69+
if (raw == null) return { kind: 'miss' };
70+
71+
let record = null;
72+
if (typeof raw === 'string') {
73+
try {
74+
record = JSON.parse(raw);
75+
} catch {
76+
record = null;
77+
}
78+
}
79+
80+
if (!record || typeof record !== 'object') return { kind: 'disabled' };
81+
82+
if (record.state === 'processing') {
83+
return {
84+
kind: 'conflict',
85+
response: jsonResponse(
86+
409,
87+
{
88+
error: 'idempotency_conflict',
89+
message: `A request with this ${IDEMPOTENCY_HEADER} is still being processed. Retry shortly.`,
90+
},
91+
corsHeaders,
92+
{ 'Retry-After': '2', [IDEMPOTENCY_HEADER]: idempotencyKey },
93+
),
94+
};
95+
}
96+
97+
if (record.state !== 'completed') return { kind: 'disabled' };
98+
99+
if (record.reqHash !== reqHash) {
100+
return {
101+
kind: 'mismatch',
102+
response: jsonResponse(
103+
422,
104+
{
105+
error: 'idempotency_key_reused',
106+
message: `This ${IDEMPOTENCY_HEADER} was already used with a different request body.`,
107+
},
108+
corsHeaders,
109+
{ [IDEMPOTENCY_HEADER]: idempotencyKey },
110+
),
111+
};
112+
}
113+
114+
return {
115+
kind: 'replay',
116+
response: new Response(record.body, {
117+
status: record.status,
118+
headers: {
119+
'Content-Type': record.contentType ?? 'application/json',
120+
'Cache-Control': 'no-store',
121+
...corsHeaders,
122+
[IDEMPOTENCY_HEADER]: idempotencyKey,
123+
[IDEMPOTENT_REPLAYED_HEADER]: 'true',
124+
},
125+
}),
126+
};
127+
}
128+
129+
async function releaseProcessingLock(redisKey) {
130+
await redisPipeline([['DEL', redisKey]]);
131+
}
132+
133+
export async function peekStandaloneIdempotency({
134+
request,
135+
pathname,
136+
scope,
137+
idempotencyKey,
138+
corsHeaders,
139+
}) {
140+
if (!isValidIdempotencyKey(idempotencyKey)) {
141+
return {
142+
kind: 'invalid',
143+
response: jsonResponse(
144+
400,
145+
{
146+
error: 'invalid_idempotency_key',
147+
message: `The ${IDEMPOTENCY_HEADER} header must be 1-${KEY_MAX_LENGTH} printable ASCII characters.`,
148+
},
149+
corsHeaders,
150+
),
151+
};
152+
}
153+
154+
const resolved = await getRequestHashAndRedisKey(request, pathname, scope, idempotencyKey);
155+
if (!resolved) return { kind: 'disabled' };
156+
157+
const pipeline = await redisPipeline([['GET', resolved.redisKey]]);
158+
if (!pipeline || pipeline.length < 1) return { kind: 'disabled' };
159+
160+
const entry = pipeline[0];
161+
if (entry?.error) return { kind: 'disabled' };
162+
163+
return outcomeFromStoredRecord(entry?.result, resolved.reqHash, idempotencyKey, corsHeaders);
164+
}
165+
166+
export async function beginStandaloneIdempotency({
167+
request,
168+
pathname,
169+
scope,
170+
idempotencyKey,
171+
corsHeaders,
172+
completedTtlSeconds = DEFAULT_COMPLETED_TTL_SECONDS,
173+
}) {
174+
if (!isValidIdempotencyKey(idempotencyKey)) {
175+
return {
176+
kind: 'invalid',
177+
response: jsonResponse(
178+
400,
179+
{
180+
error: 'invalid_idempotency_key',
181+
message: `The ${IDEMPOTENCY_HEADER} header must be 1-${KEY_MAX_LENGTH} printable ASCII characters.`,
182+
},
183+
corsHeaders,
184+
),
185+
};
186+
}
187+
188+
const resolved = await getRequestHashAndRedisKey(request, pathname, scope, idempotencyKey);
189+
if (!resolved) return { kind: 'disabled' };
190+
191+
const pipeline = await redisPipeline([
192+
['SET', resolved.redisKey, PROCESSING_MARKER, 'NX', 'EX', String(PROCESSING_TTL_SECONDS)],
193+
['GET', resolved.redisKey],
194+
]);
195+
if (!pipeline || pipeline.length < 2) return { kind: 'disabled' };
196+
197+
const claim = pipeline[0];
198+
if (claim?.error) return { kind: 'disabled' };
199+
200+
if (claim?.result === 'OK') {
201+
return {
202+
kind: 'proceed',
203+
key: idempotencyKey,
204+
store: (status, body, contentType) =>
205+
storeStandaloneResult(resolved.redisKey, status, body, contentType, resolved.reqHash, completedTtlSeconds),
206+
};
207+
}
208+
209+
const outcome = outcomeFromStoredRecord(pipeline[1]?.result, resolved.reqHash, idempotencyKey, corsHeaders);
210+
return outcome.kind === 'miss' ? { kind: 'disabled' } : outcome;
211+
}
212+
213+
export function getIdempotencyKey(request) {
214+
return request.headers.get(IDEMPOTENCY_HEADER);
215+
}
216+
217+
export async function completeStandaloneIdempotency(idempotency, response) {
218+
if (!idempotency || idempotency.kind !== 'proceed') return response;
219+
220+
const body = await response.arrayBuffer();
221+
await idempotency.store(response.status, body, response.headers.get('content-type'));
222+
223+
const headers = new Headers(response.headers);
224+
headers.set(IDEMPOTENCY_HEADER, idempotency.key);
225+
headers.set(IDEMPOTENT_REPLAYED_HEADER, 'false');
226+
return new Response(body, {
227+
status: response.status,
228+
statusText: response.statusText,
229+
headers,
230+
});
231+
}
232+
233+
async function storeStandaloneResult(redisKey, status, body, contentType, reqHash, completedTtlSeconds) {
234+
try {
235+
if (
236+
isRetryableStatus(status) ||
237+
body.byteLength > MAX_STORED_BODY_BYTES ||
238+
!isReplayableTextBody(contentType)
239+
) {
240+
await releaseProcessingLock(redisKey);
241+
return;
242+
}
243+
244+
const record = {
245+
state: 'completed',
246+
status,
247+
contentType,
248+
reqHash,
249+
body: new TextDecoder().decode(body),
250+
};
251+
const pipeline = await redisPipeline([
252+
['SET', redisKey, JSON.stringify(record), 'EX', String(completedTtlSeconds)],
253+
]);
254+
const setResult = pipeline?.[0];
255+
if (setResult?.error || setResult?.result !== 'OK') await releaseProcessingLock(redisKey);
256+
} catch {
257+
try {
258+
await releaseProcessingLock(redisKey);
259+
} catch {
260+
// Ignore release failures; the processing marker has a short TTL.
261+
}
262+
}
263+
}

api/create-checkout.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export const config = { runtime: 'edge' };
1515
import { getCorsHeaders } from './_cors.js';
1616
// @ts-expect-error — JS module, no declaration file
1717
import { captureSilentError } from './_sentry-edge.js';
18+
import {
19+
beginStandaloneIdempotency,
20+
completeStandaloneIdempotency,
21+
getIdempotencyKey,
22+
} from './_idempotency.js';
1823
import { validateBearerToken } from '../server/auth-session';
1924

2025
const CONVEX_SITE_URL =
@@ -45,7 +50,7 @@ export default async function handler(
4550
headers: {
4651
...cors,
4752
'Access-Control-Allow-Methods': 'POST, OPTIONS',
48-
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
53+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Idempotency-Key',
4954
},
5055
});
5156
}
@@ -64,6 +69,8 @@ export default async function handler(
6469
return json({ error: 'Unauthorized' }, 401, cors);
6570
}
6671

72+
const idempotencyRequest = req.clone();
73+
6774
// Parse request body
6875
let body: {
6976
productId?: string;
@@ -82,8 +89,27 @@ export default async function handler(
8289
return json({ error: 'productId is required' }, 400, cors);
8390
}
8491

92+
const idempotencyKey = getIdempotencyKey(req);
93+
const idempotency = idempotencyKey
94+
? await beginStandaloneIdempotency({
95+
request: idempotencyRequest,
96+
pathname: '/api/create-checkout',
97+
scope: `user:${session.userId}`,
98+
idempotencyKey,
99+
corsHeaders: cors,
100+
completedTtlSeconds: 10 * 60,
101+
})
102+
: null;
103+
if (
104+
idempotency &&
105+
idempotency.kind !== 'proceed' &&
106+
idempotency.kind !== 'disabled'
107+
) {
108+
return idempotency.response;
109+
}
110+
85111
if (!CONVEX_SITE_URL || !RELAY_SHARED_SECRET) {
86-
return json({ error: 'Service unavailable' }, 503, cors);
112+
return completeStandaloneIdempotency(idempotency, json({ error: 'Service unavailable' }, 503, cors));
87113
}
88114

89115
// Relay to Convex
@@ -118,20 +144,20 @@ export default async function handler(
118144
// to ACTIVE_SUBSCRIPTION_EXISTS would silently misroute a PAYMENT_IN_PROGRESS
119145
// (or any future) block to the wrong dialog — so fall back to a generic
120146
// code that the client classifies as a neutral block, not a duplicate sub.
121-
return json({
147+
return completeStandaloneIdempotency(idempotency, json({
122148
error: data?.error ?? 'CHECKOUT_BLOCKED',
123149
message: data?.message ?? 'This checkout could not be started.',
124150
subscription: data?.subscription,
125151
pendingPayment: data?.pendingPayment,
126-
}, 409, cors);
152+
}, 409, cors));
127153
}
128-
return json({ error: data?.error || 'Checkout creation failed' }, 502, cors);
154+
return completeStandaloneIdempotency(idempotency, json({ error: data?.error || 'Checkout creation failed' }, 502, cors));
129155
}
130156

131-
return json(data, 200, cors);
157+
return completeStandaloneIdempotency(idempotency, json(data, 200, cors));
132158
} catch (err) {
133159
console.error('[create-checkout] Relay failed:', (err as Error).message);
134160
captureSilentError(err, { tags: { route: 'api/create-checkout', step: 'relay' }, ctx });
135-
return json({ error: 'Checkout service unavailable' }, 502, cors);
161+
return completeStandaloneIdempotency(idempotency, json({ error: 'Checkout service unavailable' }, 502, cors));
136162
}
137163
}

0 commit comments

Comments
 (0)