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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Idempotent pledge creation using the `Idempotency-Key` request header.
- 24-hour response caching for duplicate pledge requests with the same idempotency key.
- Redis-backed idempotency cache with in-memory LRU fallback for non-production environments.
- `X-Idempotency-Cache` response header (`HIT` or `MISS`) for debugging cache behavior.

### Changed

## [0.6.0] - 2026-05-27

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,11 @@ Request body:

- `contributor`
- `amount`
- `assetCode`

Request headers:

- `Idempotency-Key` (optional): A unique key used to make the request idempotent. When provided, duplicate requests with the same key (for the same user and campaign) will return the cached response instead of creating a new pledge. Cache entries expire after 24 hours. The `X-Idempotency-Cache` response header indicates whether the response was served from cache (`HIT`) or generated fresh (`MISS`).

### `POST /api/campaigns/:id/pledges/reconcile`

Expand Down
185 changes: 184 additions & 1 deletion backend/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,25 @@ async function post(apiPath: string, body: unknown) {
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
return { status: response.status, data };
return { status: response.status, data, headers: response.headers };
}

async function postWithHeaders(
apiPath: string,
body: unknown,
headers: Record<string, string>,
) {
const mergedHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
const response = await fetch(`${baseUrl}${apiPath}`, {
method: 'POST',
headers: mergedHeaders,
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
return { status: response.status, data, headers: response.headers };
}

async function get(apiPath: string) {
Expand Down Expand Up @@ -646,3 +664,168 @@ describe('GET /api/stats', () => {
});
});
});

describe('POST /api/campaigns/:id/pledges with Idempotency-Key', () => {
const CONTRIBUTOR_C = `G${'D'.repeat(55)}`;
const CONTRIBUTOR_D = `G${'E'.repeat(55)}`;

async function createTestCampaign() {
const createRes = await post('/api/campaigns', {
creator: CREATOR,
title: 'Idempotency Test Campaign',
description: 'This campaign is used to test idempotency behavior.',
acceptedTokens: ['USDC'],
targetAmount: 500,
deadline: Math.floor(Date.now() / 1000) + 86400,
});
return createRes.data.data.id;
}

it('request with Idempotency-Key creates a pledge', async () => {
const campaignId = await createTestCampaign();

const res = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'test-key-1' });
Comment on lines +687 to +691

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

These tests never send Idempotency-Keypost() takes only two arguments.

post(apiPath, body) (Lines 58-66) has no third parameter, so the { 'Idempotency-Key': ... } object is silently dropped at runtime and every one of these "idempotency" tests actually exercises the unkeyed path. This also fails tsc (TS2554: expected 2 arguments, got 3) if typecheck runs in CI. Concretely, Line 513 (secondRes.data deep-equals firstRes.data) and Line 535 (pledge count unchanged) cannot hold, since the second request creates a real second pledge.

All six call sites passing a third argument (Lines 487-491, 500-511, 519-532, 553-564, 575-587, 595-606) must use postWithHeaders instead.

🐛 Proposed fix (apply the same change to each keyed call site)
-    const res = await post(`/api/campaigns/${campaignId}/pledges`, {
-      contributor: CONTRIBUTOR_C,
-      amount: 100,
-      assetCode: 'USDC',
-    }, { 'Idempotency-Key': 'test-key-1' });
+    const res = await postWithHeaders(
+      `/api/campaigns/${campaignId}/pledges`,
+      { contributor: CONTRIBUTOR_C, amount: 100, assetCode: 'USDC' },
+      { 'Idempotency-Key': 'test-key-1' },
+    );

Alternatively, give post an optional headers parameter and drop postWithHeaders to avoid two near-identical helpers.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'test-key-1' });
const res = await postWithHeaders(
`/api/campaigns/${campaignId}/pledges`,
{ contributor: CONTRIBUTOR_C, amount: 100, assetCode: 'USDC' },
{ 'Idempotency-Key': 'test-key-1' },
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/api.test.ts` around lines 487 - 491, Update all six idempotency
test call sites to use the existing postWithHeaders helper instead of post,
passing each Idempotency-Key through the headers argument. Keep unkeyed requests
on post and ensure the tests exercise the keyed idempotency path without
changing their assertions.


expect(res.status).toBe(201);
expect(res.data.data.progress.pledgeCount).toBe(1);
});

it('duplicate request with same Idempotency-Key returns cached response', async () => {
const campaignId = await createTestCampaign();

const firstRes = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'dup-key-1' });
expect(firstRes.status).toBe(201);

const secondRes = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'dup-key-1' });
expect(secondRes.status).toBe(201);
expect(secondRes.data).toEqual(firstRes.data);
});

it('duplicate request with same Idempotency-Key performs only one database write', async () => {
const campaignId = await createTestCampaign();

await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'db-write-key' });

const db = getDb();
const pledgeCountBefore = db.prepare('SELECT COUNT(*) AS count FROM pledges').get() as { count: number };

await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'db-write-key' });

const pledgeCountAfter = db.prepare('SELECT COUNT(*) AS count FROM pledges').get() as { count: number };
expect(pledgeCountAfter.count).toBe(pledgeCountBefore.count);
});

it('missing Idempotency-Key behaves exactly as before', async () => {
const campaignId = await createTestCampaign();

const res = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
});
expect(res.status).toBe(201);
expect(res.data.data.progress.pledgeCount).toBe(1);
});

it('different idempotency keys create independent pledges', async () => {
const campaignId = await createTestCampaign();

const res1 = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 50,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'key-A' });
expect(res1.status).toBe(201);

const res2 = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 50,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'key-B' });
expect(res2.status).toBe(201);

const db = getDb();
const pledgeCount = db.prepare('SELECT COUNT(*) AS count FROM pledges').get() as { count: number };
expect(pledgeCount.count).toBe(2);
});

it('different users using the same idempotency key do not share cached responses', async () => {
const campaignId = await createTestCampaign();

const userARes = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'shared-key' });
expect(userARes.status).toBe(201);
expect(userARes.data.data.progress.pledgeCount).toBe(1);

const userBRes = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_D,
amount: 100,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'shared-key' });
expect(userBRes.status).toBe(201);
expect(userBRes.data.data.progress.pledgeCount).toBe(2);
});

it('cached response preserves original status and payload', async () => {
const campaignId = await createTestCampaign();

const firstRes = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 75,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'status-payload-key' });
expect(firstRes.status).toBe(201);

const cachedRes = await post(`/api/campaigns/${campaignId}/pledges`, {
contributor: CONTRIBUTOR_C,
amount: 75,
assetCode: 'USDC',
}, { 'Idempotency-Key': 'status-payload-key' });
expect(cachedRes.status).toBe(201);
expect(cachedRes.data.data.id).toBe(firstRes.data.data.id);
expect(cachedRes.data.data.amount).toBe(firstRes.data.data.amount);
});

it('X-Idempotency-Cache header is MISS on first request and HIT on duplicate', async () => {
const campaignId = await createTestCampaign();

const firstRes = await postWithHeaders(
`/api/campaigns/${campaignId}/pledges`,
{ contributor: CONTRIBUTOR_C, amount: 100, assetCode: 'USDC' },
{ 'Idempotency-Key': 'header-test-key' },
);
expect(firstRes.status).toBe(201);
expect(firstRes.headers.get('X-Idempotency-Cache')).toBe('MISS');

const secondRes = await postWithHeaders(
`/api/campaigns/${campaignId}/pledges`,
{ contributor: CONTRIBUTOR_C, amount: 100, assetCode: 'USDC' },
{ 'Idempotency-Key': 'header-test-key' },
);
expect(secondRes.status).toBe(201);
expect(secondRes.headers.get('X-Idempotency-Cache')).toBe('HIT');
});
});
1 change: 1 addition & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ app.post(
app.post(
'/api/campaigns/:id/pledges',
applyRateLimit(WRITE_RATE_LIMIT_MAX_REQUESTS),
idempotencyMiddleware,
validateBody(createPledgePayloadSchema),
(req: Request, res: Response) => {
const parsedId = parseCampaignId(req.params.id);
Expand Down
66 changes: 66 additions & 0 deletions backend/src/middleware/idempotencyMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Request, Response, NextFunction } from 'express';
import {
getIdempotencyCacheEntry,
setIdempotencyCacheEntry,
buildIdempotencyCacheKey,
} from '../services/idempotencyCache';
import type { RequestWithApiKey } from './apiKeyAuth';

interface IdempotencyRequest extends Request {
idempotencyKey?: string;
}

export function idempotencyMiddleware(
req: IdempotencyRequest,
res: Response,
next: NextFunction,
): void {
const idempotencyKey = req.header('Idempotency-Key');

if (!idempotencyKey) {
return next();
}

const apiKey = (req as unknown as RequestWithApiKey).apiKey ?? 'anonymous';
const campaignId = req.params.id as string;
const cacheKey = buildIdempotencyCacheKey(apiKey, campaignId, idempotencyKey);
Comment on lines +24 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Scope the key to the contributor contract.

The cache key omits the contributor entirely. Requests from different contributors with the same API key—or any requests using the anonymous fallback—can replay another contributor’s pledge response instead of creating the intended pledge. Include the validated contributor identity in the cache scope (or reject reuse of a key with a different request fingerprint).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/middleware/idempotencyMiddleware.ts` around lines 24 - 26, Update
the idempotency cache-key construction in the middleware around
buildIdempotencyCacheKey to include the validated contributor identity alongside
apiKey and campaignId. Ensure contributor identity is available from the
validated request context, including for anonymous requests, so identical
idempotency keys cannot reuse another contributor’s pledge response.


getIdempotencyCacheEntry(cacheKey).then(
(cached) => {
if (cached) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('X-Idempotency-Cache', 'HIT');
for (const [name, value] of Object.entries(cached.headers)) {
res.setHeader(name, value);
}
res.status(cached.statusCode).send(cached.body);
return;
}

const originalSend = res.send.bind(res);
res.send = function (data: unknown) {
if (res.statusCode >= 200 && res.statusCode < 300) {
const body = typeof data === 'string' ? data : JSON.stringify(data);
const entry = {
statusCode: res.statusCode,
body,
headers: {
'Content-Type': res.getHeader('Content-Type') as string,
},
};
setIdempotencyCacheEntry(cacheKey, entry).catch(() => {
// Silently fail cache writes
});
res.setHeader('X-Idempotency-Cache', 'MISS');
}

return originalSend(data);
};

next();
},
() => {
next();
},
);
}
1 change: 1 addition & 0 deletions backend/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ registry.registerPath({
path: '/api/campaigns/{id}/pledges',
tags: ['Pledges'],
summary: 'Create a pledge',
description: 'Creates a pledge for a campaign. Use the Idempotency-Key header to make the request idempotent. Cached responses are returned for 24 hours.',
request: {
params: z.object({ id: campaignIdParamSchema }),
body: {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export async function initRedisCache(): Promise<void> {
} catch {
redisClient = null;
isConnected = false;
} catch {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
redisClient = null;
isConnected = false;
}
}

Expand Down
62 changes: 62 additions & 0 deletions backend/src/services/idempotencyCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { LRUCache } from 'lru-cache';
import { getCacheValue, setCacheValue, isCacheAvailable } from './cache';

const IDEMPOTENCY_TTL_SECONDS = 86_400;

const MAX_CACHE_SIZE = Number(process.env.IDEMPOTENCY_CACHE_MAX_SIZE ?? 1000);

interface IdempotencyCacheEntry {
statusCode: number;
body: string;
headers: Record<string, string>;
}

const memoryCache = new LRUCache<string, IdempotencyCacheEntry>({
max: MAX_CACHE_SIZE,
ttl: IDEMPOTENCY_TTL_SECONDS * 1000,
});

export function buildIdempotencyCacheKey(
apiKey: string,
campaignId: string,
idempotencyKey: string,
): string {
return `idempotency:${apiKey}:${campaignId}:${idempotencyKey}`;
}

export async function getIdempotencyCacheEntry(
key: string,
): Promise<IdempotencyCacheEntry | null> {
if (isCacheAvailable()) {
const cached = await getCacheValue(key);
if (cached) {
return JSON.parse(cached) as IdempotencyCacheEntry;
}
}

const memoryEntry = memoryCache.get(key);
if (memoryEntry) {
return memoryEntry;
}

return null;
}

export async function setIdempotencyCacheEntry(
key: string,
entry: IdempotencyCacheEntry,
): Promise<void> {
const serialized = JSON.stringify(entry);

if (isCacheAvailable()) {
await setCacheValue(key, serialized, IDEMPOTENCY_TTL_SECONDS).catch(() => {
// Silently fail Redis writes; memory cache still works
});
}

memoryCache.set(key, entry);
}
Comment on lines +27 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Make idempotency acquisition atomic.

A lookup followed by a later write cannot prevent two concurrent requests with the same key from both missing and reaching the database. Add an atomic “claim/in-progress” operation (for Redis, e.g. SET ... NX) before invoking the handler; waiters must replay/wait for the winning response rather than call next().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/idempotencyCache.ts` around lines 27 - 58, Replace the
separate lookup/write flow around getIdempotencyCacheEntry and
setIdempotencyCacheEntry with an atomic claim operation performed before the
handler runs, using Redis’s conditional create semantics (such as SET NX) and an
equivalent memory-cache guard. When a key is already claimed, wait for or replay
the winning request’s stored response instead of invoking next(); ensure the
winning response is published through the existing cache path for subsequent
waiters.


export function clearIdempotencyCache(): void {
memoryCache.clear();
}