Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions src/middleware/idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,13 @@ export async function idempotencyMiddleware(
}
await db.query('DELETE FROM idempotency_store WHERE expires_at < $1', [new Date().toISOString()]);

const result = await db.query(
'SELECT request_hash, status, response_status, response_body, expires_at FROM idempotency_store WHERE idempotency_key = $1',
[idempotencyKey]
);

if (result.rows.length > 0) {
const record = result.rows[0];
const handleExistingRecord = (record: {
request_hash: string;
status: string;
response_status: number;
response_body: string;
expires_at: string | Date;
}): boolean => {
const expiresAt = new Date(record.expires_at);

if (expiresAt > new Date()) {
Expand Down Expand Up @@ -220,7 +220,7 @@ export async function idempotencyMiddleware(
incomingFields: incomingKeys,
}
);
return;
return true;
}

if (record.status === 'completed') {
Expand All @@ -233,7 +233,7 @@ export async function idempotencyMiddleware(
});
res.setHeader('Idempotent-Replayed', 'true');
res.status(record.response_status).json(JSON.parse(record.response_body));
return;
return true;
}

if (record.status === 'started') {
Expand All @@ -251,20 +251,43 @@ export async function idempotencyMiddleware(
opts?.inProgressErrorCode ?? 'IDEMPOTENCY_IN_PROGRESS',
'Request already in progress'
);
return;
return true;
}
}
return false;
};

const result = await db.query(
'SELECT request_hash, status, response_status, response_body, expires_at FROM idempotency_store WHERE idempotency_key = $1',
[idempotencyKey]
);

if (result.rows.length > 0) {
if (handleExistingRecord(result.rows[0])) {
return;
}
}

const retentionSeconds = opts?.retentionSeconds ?? config.idempotency.retentionWindowSeconds;
const expiresAtDate = new Date(Date.now() + retentionSeconds * 1000);

await db.query(
const insertResult = await db.query(
`INSERT INTO idempotency_store (idempotency_key, request_hash, status, expires_at, created_at)
VALUES ($1, $2, $3, $4, NOW()::timestamp)`,
VALUES ($1, $2, $3, $4, NOW()::timestamp)
ON CONFLICT (idempotency_key) DO NOTHING`,
[idempotencyKey, requestHash, 'started', expiresAtDate.toISOString()]
);

if (insertResult && insertResult.rowCount === 0) {
const existing = await db.query(
'SELECT request_hash, status, response_status, response_body, expires_at FROM idempotency_store WHERE idempotency_key = $1',
[idempotencyKey]
);
if (existing.rows.length > 0 && handleExistingRecord(existing.rows[0])) {
return;
}
}

const originalSend = res.send;
const originalJson = res.json;
let saved = false;
Expand Down
37 changes: 36 additions & 1 deletion src/routes/billing/refund.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ function makeIdempotencyPool(): Pool {
}
if (text.includes('INSERT INTO idempotency_store')) {
const [key, requestHash, status, expiresAt] = params as [string, string, string, string];
if (store.has(key)) {
return { rows: [], rowCount: 0 };
}
store.set(key, { request_hash: requestHash, status, response_status: 0, response_body: '', expires_at: expiresAt });
return { rows: [] };
return { rows: [], rowCount: 1 };
}
if (text.includes('UPDATE idempotency_store')) {
const [status, responseStatus, responseBody, key] = params as [string, number, string, string];
Expand Down Expand Up @@ -247,4 +250,36 @@ describe('POST /api/billing/refund', () => {
expect(second.body.success).toBe(false);
expect(grant).toHaveBeenCalledTimes(1);
});

it('prevents duplicate refunds on concurrent retries with the same Idempotency-Key', async () => {
let grantCallCount = 0;
const grant = jest.fn().mockImplementation(async () => {
grantCallCount++;
// simulate slight async latency
await new Promise(resolve => setTimeout(resolve, 20));
return makeCredit({ balance_usdc: '15.00' });
});
const pool = makeIdempotencyPool();
const app = buildApp({ pool, creditsRepository: { grant } as unknown as CreditsRepository });

const [res1, res2] = await Promise.all([
request(app)
.post('/api/billing/refund')
.set('x-admin-api-key', ADMIN_KEY)
.set('idempotency-key', 'refund-key-concurrent')
.send(validPayload),
request(app)
.post('/api/billing/refund')
.set('x-admin-api-key', ADMIN_KEY)
.set('idempotency-key', 'refund-key-concurrent')
.send(validPayload),
]);

const statuses = [res1.status, res2.status].sort();
// One request must succeed (200), and the concurrent conflicting one must be rejected (409) or replayed
expect(statuses).toEqual([200, 409]);
expect(grant).toHaveBeenCalledTimes(1);
expect(grantCallCount).toBe(1);
});
});