Skip to content

Commit 110b71e

Browse files
authored
Merge pull request #717 from David-Adegboyega/feat/560-idempotent-pledge-endpoint
feat(api): add idempotent pledge endpoint
2 parents 01ddb23 + 1289393 commit 110b71e

8 files changed

Lines changed: 331 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Idempotent pledge creation using the `Idempotency-Key` request header.
13+
- 24-hour response caching for duplicate pledge requests with the same idempotency key.
14+
- Redis-backed idempotency cache with in-memory LRU fallback for non-production environments.
15+
- `X-Idempotency-Cache` response header (`HIT` or `MISS`) for debugging cache behavior.
16+
17+
### Changed
18+
1019
## [0.6.0] - 2026-05-27
1120

1221
### Added

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@ Request body:
328328

329329
- `contributor`
330330
- `amount`
331+
- `assetCode`
332+
333+
Request headers:
334+
335+
- `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`).
331336

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

backend/src/api.test.ts

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,25 @@ async function post(apiPath: string, body: unknown) {
7070
body: JSON.stringify(body),
7171
});
7272
const data = await response.json().catch(() => null);
73-
return { status: response.status, data };
73+
return { status: response.status, data, headers: response.headers };
74+
}
75+
76+
async function postWithHeaders(
77+
apiPath: string,
78+
body: unknown,
79+
headers: Record<string, string>,
80+
) {
81+
const mergedHeaders: Record<string, string> = {
82+
'Content-Type': 'application/json',
83+
...headers,
84+
};
85+
const response = await fetch(`${baseUrl}${apiPath}`, {
86+
method: 'POST',
87+
headers: mergedHeaders,
88+
body: JSON.stringify(body),
89+
});
90+
const data = await response.json().catch(() => null);
91+
return { status: response.status, data, headers: response.headers };
7492
}
7593

7694
async function get(apiPath: string) {
@@ -647,3 +665,168 @@ describe('GET /api/stats', () => {
647665
});
648666
});
649667
});
668+
669+
describe('POST /api/campaigns/:id/pledges with Idempotency-Key', () => {
670+
const CONTRIBUTOR_C = `G${'D'.repeat(55)}`;
671+
const CONTRIBUTOR_D = `G${'E'.repeat(55)}`;
672+
673+
async function createTestCampaign() {
674+
const createRes = await post('/api/campaigns', {
675+
creator: CREATOR,
676+
title: 'Idempotency Test Campaign',
677+
description: 'This campaign is used to test idempotency behavior.',
678+
acceptedTokens: ['USDC'],
679+
targetAmount: 500,
680+
deadline: Math.floor(Date.now() / 1000) + 86400,
681+
});
682+
return createRes.data.data.id;
683+
}
684+
685+
it('request with Idempotency-Key creates a pledge', async () => {
686+
const campaignId = await createTestCampaign();
687+
688+
const res = await post(`/api/campaigns/${campaignId}/pledges`, {
689+
contributor: CONTRIBUTOR_C,
690+
amount: 100,
691+
assetCode: 'USDC',
692+
}, { 'Idempotency-Key': 'test-key-1' });
693+
694+
expect(res.status).toBe(201);
695+
expect(res.data.data.progress.pledgeCount).toBe(1);
696+
});
697+
698+
it('duplicate request with same Idempotency-Key returns cached response', async () => {
699+
const campaignId = await createTestCampaign();
700+
701+
const firstRes = await post(`/api/campaigns/${campaignId}/pledges`, {
702+
contributor: CONTRIBUTOR_C,
703+
amount: 100,
704+
assetCode: 'USDC',
705+
}, { 'Idempotency-Key': 'dup-key-1' });
706+
expect(firstRes.status).toBe(201);
707+
708+
const secondRes = await post(`/api/campaigns/${campaignId}/pledges`, {
709+
contributor: CONTRIBUTOR_C,
710+
amount: 100,
711+
assetCode: 'USDC',
712+
}, { 'Idempotency-Key': 'dup-key-1' });
713+
expect(secondRes.status).toBe(201);
714+
expect(secondRes.data).toEqual(firstRes.data);
715+
});
716+
717+
it('duplicate request with same Idempotency-Key performs only one database write', async () => {
718+
const campaignId = await createTestCampaign();
719+
720+
await post(`/api/campaigns/${campaignId}/pledges`, {
721+
contributor: CONTRIBUTOR_C,
722+
amount: 100,
723+
assetCode: 'USDC',
724+
}, { 'Idempotency-Key': 'db-write-key' });
725+
726+
const db = getDb();
727+
const pledgeCountBefore = db.prepare('SELECT COUNT(*) AS count FROM pledges').get() as { count: number };
728+
729+
await post(`/api/campaigns/${campaignId}/pledges`, {
730+
contributor: CONTRIBUTOR_C,
731+
amount: 100,
732+
assetCode: 'USDC',
733+
}, { 'Idempotency-Key': 'db-write-key' });
734+
735+
const pledgeCountAfter = db.prepare('SELECT COUNT(*) AS count FROM pledges').get() as { count: number };
736+
expect(pledgeCountAfter.count).toBe(pledgeCountBefore.count);
737+
});
738+
739+
it('missing Idempotency-Key behaves exactly as before', async () => {
740+
const campaignId = await createTestCampaign();
741+
742+
const res = await post(`/api/campaigns/${campaignId}/pledges`, {
743+
contributor: CONTRIBUTOR_C,
744+
amount: 100,
745+
assetCode: 'USDC',
746+
});
747+
expect(res.status).toBe(201);
748+
expect(res.data.data.progress.pledgeCount).toBe(1);
749+
});
750+
751+
it('different idempotency keys create independent pledges', async () => {
752+
const campaignId = await createTestCampaign();
753+
754+
const res1 = await post(`/api/campaigns/${campaignId}/pledges`, {
755+
contributor: CONTRIBUTOR_C,
756+
amount: 50,
757+
assetCode: 'USDC',
758+
}, { 'Idempotency-Key': 'key-A' });
759+
expect(res1.status).toBe(201);
760+
761+
const res2 = await post(`/api/campaigns/${campaignId}/pledges`, {
762+
contributor: CONTRIBUTOR_C,
763+
amount: 50,
764+
assetCode: 'USDC',
765+
}, { 'Idempotency-Key': 'key-B' });
766+
expect(res2.status).toBe(201);
767+
768+
const db = getDb();
769+
const pledgeCount = db.prepare('SELECT COUNT(*) AS count FROM pledges').get() as { count: number };
770+
expect(pledgeCount.count).toBe(2);
771+
});
772+
773+
it('different users using the same idempotency key do not share cached responses', async () => {
774+
const campaignId = await createTestCampaign();
775+
776+
const userARes = await post(`/api/campaigns/${campaignId}/pledges`, {
777+
contributor: CONTRIBUTOR_C,
778+
amount: 100,
779+
assetCode: 'USDC',
780+
}, { 'Idempotency-Key': 'shared-key' });
781+
expect(userARes.status).toBe(201);
782+
expect(userARes.data.data.progress.pledgeCount).toBe(1);
783+
784+
const userBRes = await post(`/api/campaigns/${campaignId}/pledges`, {
785+
contributor: CONTRIBUTOR_D,
786+
amount: 100,
787+
assetCode: 'USDC',
788+
}, { 'Idempotency-Key': 'shared-key' });
789+
expect(userBRes.status).toBe(201);
790+
expect(userBRes.data.data.progress.pledgeCount).toBe(2);
791+
});
792+
793+
it('cached response preserves original status and payload', async () => {
794+
const campaignId = await createTestCampaign();
795+
796+
const firstRes = await post(`/api/campaigns/${campaignId}/pledges`, {
797+
contributor: CONTRIBUTOR_C,
798+
amount: 75,
799+
assetCode: 'USDC',
800+
}, { 'Idempotency-Key': 'status-payload-key' });
801+
expect(firstRes.status).toBe(201);
802+
803+
const cachedRes = await post(`/api/campaigns/${campaignId}/pledges`, {
804+
contributor: CONTRIBUTOR_C,
805+
amount: 75,
806+
assetCode: 'USDC',
807+
}, { 'Idempotency-Key': 'status-payload-key' });
808+
expect(cachedRes.status).toBe(201);
809+
expect(cachedRes.data.data.id).toBe(firstRes.data.data.id);
810+
expect(cachedRes.data.data.amount).toBe(firstRes.data.data.amount);
811+
});
812+
813+
it('X-Idempotency-Cache header is MISS on first request and HIT on duplicate', async () => {
814+
const campaignId = await createTestCampaign();
815+
816+
const firstRes = await postWithHeaders(
817+
`/api/campaigns/${campaignId}/pledges`,
818+
{ contributor: CONTRIBUTOR_C, amount: 100, assetCode: 'USDC' },
819+
{ 'Idempotency-Key': 'header-test-key' },
820+
);
821+
expect(firstRes.status).toBe(201);
822+
expect(firstRes.headers.get('X-Idempotency-Cache')).toBe('MISS');
823+
824+
const secondRes = await postWithHeaders(
825+
`/api/campaigns/${campaignId}/pledges`,
826+
{ contributor: CONTRIBUTOR_C, amount: 100, assetCode: 'USDC' },
827+
{ 'Idempotency-Key': 'header-test-key' },
828+
);
829+
expect(secondRes.status).toBe(201);
830+
expect(secondRes.headers.get('X-Idempotency-Cache')).toBe('HIT');
831+
});
832+
});

backend/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ app.post(
596596
app.post(
597597
'/api/campaigns/:id/pledges',
598598
applyRateLimit(WRITE_RATE_LIMIT_MAX_REQUESTS),
599+
idempotencyMiddleware,
599600
validateBody(createPledgePayloadSchema),
600601
(req: Request, res: Response) => {
601602
const parsedId = parseCampaignId(req.params.id);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Request, Response, NextFunction } from 'express';
2+
import {
3+
getIdempotencyCacheEntry,
4+
setIdempotencyCacheEntry,
5+
buildIdempotencyCacheKey,
6+
} from '../services/idempotencyCache';
7+
import type { RequestWithApiKey } from './apiKeyAuth';
8+
9+
interface IdempotencyRequest extends Request {
10+
idempotencyKey?: string;
11+
}
12+
13+
export function idempotencyMiddleware(
14+
req: IdempotencyRequest,
15+
res: Response,
16+
next: NextFunction,
17+
): void {
18+
const idempotencyKey = req.header('Idempotency-Key');
19+
20+
if (!idempotencyKey) {
21+
return next();
22+
}
23+
24+
const apiKey = (req as unknown as RequestWithApiKey).apiKey ?? 'anonymous';
25+
const campaignId = req.params.id as string;
26+
const cacheKey = buildIdempotencyCacheKey(apiKey, campaignId, idempotencyKey);
27+
28+
getIdempotencyCacheEntry(cacheKey).then(
29+
(cached) => {
30+
if (cached) {
31+
res.setHeader('Content-Type', 'application/json');
32+
res.setHeader('X-Idempotency-Cache', 'HIT');
33+
for (const [name, value] of Object.entries(cached.headers)) {
34+
res.setHeader(name, value);
35+
}
36+
res.status(cached.statusCode).send(cached.body);
37+
return;
38+
}
39+
40+
const originalSend = res.send.bind(res);
41+
res.send = function (data: unknown) {
42+
if (res.statusCode >= 200 && res.statusCode < 300) {
43+
const body = typeof data === 'string' ? data : JSON.stringify(data);
44+
const entry = {
45+
statusCode: res.statusCode,
46+
body,
47+
headers: {
48+
'Content-Type': res.getHeader('Content-Type') as string,
49+
},
50+
};
51+
setIdempotencyCacheEntry(cacheKey, entry).catch(() => {
52+
// Silently fail cache writes
53+
});
54+
res.setHeader('X-Idempotency-Cache', 'MISS');
55+
}
56+
57+
return originalSend(data);
58+
};
59+
60+
next();
61+
},
62+
() => {
63+
next();
64+
},
65+
);
66+
}

backend/src/openapi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ registry.registerPath({
638638
path: '/api/campaigns/{id}/pledges',
639639
tags: ['Pledges'],
640640
summary: 'Create a pledge',
641+
description: 'Creates a pledge for a campaign. Use the Idempotency-Key header to make the request idempotent. Cached responses are returned for 24 hours.',
641642
request: {
642643
params: z.object({ id: campaignIdParamSchema }),
643644
body: {

backend/src/services/cache.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export async function initRedisCache(): Promise<void> {
3939
} catch {
4040
redisClient = null;
4141
isConnected = false;
42+
} catch {
43+
redisClient = null;
44+
isConnected = false;
4245
}
4346
}
4447

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { LRUCache } from 'lru-cache';
2+
import { getCacheValue, setCacheValue, isCacheAvailable } from './cache';
3+
4+
const IDEMPOTENCY_TTL_SECONDS = 86_400;
5+
6+
const MAX_CACHE_SIZE = Number(process.env.IDEMPOTENCY_CACHE_MAX_SIZE ?? 1000);
7+
8+
interface IdempotencyCacheEntry {
9+
statusCode: number;
10+
body: string;
11+
headers: Record<string, string>;
12+
}
13+
14+
const memoryCache = new LRUCache<string, IdempotencyCacheEntry>({
15+
max: MAX_CACHE_SIZE,
16+
ttl: IDEMPOTENCY_TTL_SECONDS * 1000,
17+
});
18+
19+
export function buildIdempotencyCacheKey(
20+
apiKey: string,
21+
campaignId: string,
22+
idempotencyKey: string,
23+
): string {
24+
return `idempotency:${apiKey}:${campaignId}:${idempotencyKey}`;
25+
}
26+
27+
export async function getIdempotencyCacheEntry(
28+
key: string,
29+
): Promise<IdempotencyCacheEntry | null> {
30+
if (isCacheAvailable()) {
31+
const cached = await getCacheValue(key);
32+
if (cached) {
33+
return JSON.parse(cached) as IdempotencyCacheEntry;
34+
}
35+
}
36+
37+
const memoryEntry = memoryCache.get(key);
38+
if (memoryEntry) {
39+
return memoryEntry;
40+
}
41+
42+
return null;
43+
}
44+
45+
export async function setIdempotencyCacheEntry(
46+
key: string,
47+
entry: IdempotencyCacheEntry,
48+
): Promise<void> {
49+
const serialized = JSON.stringify(entry);
50+
51+
if (isCacheAvailable()) {
52+
await setCacheValue(key, serialized, IDEMPOTENCY_TTL_SECONDS).catch(() => {
53+
// Silently fail Redis writes; memory cache still works
54+
});
55+
}
56+
57+
memoryCache.set(key, entry);
58+
}
59+
60+
export function clearIdempotencyCache(): void {
61+
memoryCache.clear();
62+
}

0 commit comments

Comments
 (0)