fix(#24): Implement idempotency protection for critical write operations
Some checks are pending
Docker Test / test (push) Waiting to run
Some checks are pending
Docker Test / test (push) Waiting to run
This commit is contained in:
parent
6c25464369
commit
b44e7bf46c
6 changed files with 281 additions and 4 deletions
9
backend/migrations/002_idempotency_table.sql
Normal file
9
backend/migrations/002_idempotency_table.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Migration: 002_idempotency_table
|
||||||
|
-- Description: Add idempotency_keys table for handling idempotent requests
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
response_body TEXT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
113
backend/src/__tests__/idempotency.test.js
Normal file
113
backend/src/__tests__/idempotency.test.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('assert');
|
||||||
|
const { app } = require('../app');
|
||||||
|
|
||||||
|
// Test idempotency for offers creation
|
||||||
|
test('POST /offers/:requestId should handle idempotent requests', async () => {
|
||||||
|
// First request with idempotency key
|
||||||
|
const firstResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/offers/1',
|
||||||
|
headers: {
|
||||||
|
'x-idempotency-key': 'test-key-123'
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
amountChf: 50,
|
||||||
|
message: 'I can help with this'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(firstResponse.statusCode, 201);
|
||||||
|
|
||||||
|
// Second request with same idempotency key should return cached response
|
||||||
|
const secondResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/offers/1',
|
||||||
|
headers: {
|
||||||
|
'x-idempotency-key': 'test-key-123'
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
amountChf: 50,
|
||||||
|
message: 'I can help with this'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(secondResponse.statusCode, 201);
|
||||||
|
// Note: We're not checking the exact response body here as it's complex to parse
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test idempotency for offer acceptance
|
||||||
|
test('POST /offers/accept/:offerId should handle idempotent requests', async () => {
|
||||||
|
// First request with idempotency key
|
||||||
|
const firstResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/offers/accept/1',
|
||||||
|
headers: {
|
||||||
|
'x-idempotency-key': 'test-accept-key-456'
|
||||||
|
},
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(firstResponse.statusCode, 201);
|
||||||
|
|
||||||
|
// Second request with same idempotency key should return cached response
|
||||||
|
const secondResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/offers/accept/1',
|
||||||
|
headers: {
|
||||||
|
'x-idempotency-key': 'test-accept-key-456'
|
||||||
|
},
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(secondResponse.statusCode, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test idempotency for help requests creation
|
||||||
|
test('POST /help-requests should handle idempotent requests', async () => {
|
||||||
|
// First request with idempotency key
|
||||||
|
const firstResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/help-requests',
|
||||||
|
headers: {
|
||||||
|
'x-idempotency-key': 'test-help-key-789'
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
title: 'Test Help Request',
|
||||||
|
description: 'This is a test help request',
|
||||||
|
valueChf: 100
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(firstResponse.statusCode, 201);
|
||||||
|
|
||||||
|
// Second request with same idempotency key should return cached response
|
||||||
|
const secondResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/help-requests',
|
||||||
|
headers: {
|
||||||
|
'x-idempotency-key': 'test-help-key-789'
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
title: 'Test Help Request',
|
||||||
|
description: 'This is a test help request',
|
||||||
|
valueChf: 100
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(secondResponse.statusCode, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that non-idempotent requests work normally
|
||||||
|
test('POST /offers/:requestId should work without idempotency key', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/offers/1',
|
||||||
|
payload: {
|
||||||
|
amountChf: 50,
|
||||||
|
message: 'I can help with this'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(response.statusCode, 201);
|
||||||
|
});
|
||||||
70
backend/src/middleware/idempotency.js
Normal file
70
backend/src/middleware/idempotency.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { pool } from '../db/connection.js';
|
||||||
|
|
||||||
|
// In-memory cache for idempotency keys (in production, use Redis or similar)
|
||||||
|
const idempotencyCache = new Map();
|
||||||
|
|
||||||
|
export const requireIdempotencyKey = async (req, res, next) => {
|
||||||
|
// Only apply to POST requests
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers['x-idempotency-key'];
|
||||||
|
|
||||||
|
// If no key provided, proceed normally (non-idempotent request)
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if this key was used before
|
||||||
|
const [existing] = await pool.query(
|
||||||
|
'SELECT id, response_body, created_at FROM idempotency_keys WHERE key = ?',
|
||||||
|
[idempotencyKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Key already exists, return cached response
|
||||||
|
const cachedResponse = JSON.parse(existing[0].response_body);
|
||||||
|
return res.status(cachedResponse.status).json(cachedResponse.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the key with a placeholder for response
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO idempotency_keys (key, created_at) VALUES (?, NOW())',
|
||||||
|
[idempotencyKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a function to cache the response
|
||||||
|
req.cacheIdempotentResponse = async (status, body) => {
|
||||||
|
const responseBody = JSON.stringify({ status, body });
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE idempotency_keys SET response_body = ? WHERE key = ?',
|
||||||
|
[responseBody, idempotencyKey]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in idempotency middleware:', error);
|
||||||
|
// If database fails, proceed without idempotency
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create a unique key for a request
|
||||||
|
export const generateIdempotencyKey = (req) => {
|
||||||
|
// Create a simple hash of the request data
|
||||||
|
const { method, url, body } = req;
|
||||||
|
const keyString = `${method}-${url}-${JSON.stringify(body)}`;
|
||||||
|
|
||||||
|
// Simple hash function (in production, use proper hashing)
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < keyString.length; i++) {
|
||||||
|
const char = keyString.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
return `idempotency-${Math.abs(hash)}`;
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pool } from '../db/connection.js';
|
import { pool } from '../db/connection.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ router.get('/', async (_req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /help-requests - Create a new help request
|
// POST /help-requests - Create a new help request
|
||||||
router.post('/', requireAuth, async (req, res) => {
|
router.post('/', requireAuth, requireIdempotencyKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const parsed = z.object({
|
const parsed = z.object({
|
||||||
title: z.string().min(3).max(180),
|
title: z.string().min(3).max(180),
|
||||||
|
|
@ -43,6 +44,11 @@ router.post('/', requireAuth, async (req, res) => {
|
||||||
[req.user.userId, title, description, valueChf]
|
[req.user.userId, title, description, valueChf]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cache the response for idempotent requests
|
||||||
|
if (req.cacheIdempotentResponse) {
|
||||||
|
await req.cacheIdempotentResponse(201, { id: result.insertId });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ id: result.insertId });
|
res.status(201).json({ id: result.insertId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating help request:', error);
|
console.error('Error creating help request:', error);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pool } from '../db/connection.js';
|
import { pool } from '../db/connection.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ const negotiateSchema = z.object({
|
||||||
message: z.string().max(2000).optional()
|
message: z.string().max(2000).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:requestId', requireAuth, async (req, res) => {
|
router.post('/:requestId', requireAuth, requireIdempotencyKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const requestId = Number(req.params.requestId);
|
const requestId = Number(req.params.requestId);
|
||||||
if (Number.isNaN(requestId)) {
|
if (Number.isNaN(requestId)) {
|
||||||
|
|
@ -39,6 +40,11 @@ router.post('/:requestId', requireAuth, async (req, res) => {
|
||||||
|
|
||||||
await pool.query('UPDATE help_requests SET status = ? WHERE id = ?', ['negotiating', requestId]);
|
await pool.query('UPDATE help_requests SET status = ? WHERE id = ?', ['negotiating', requestId]);
|
||||||
|
|
||||||
|
// Cache the response for idempotent requests
|
||||||
|
if (req.cacheIdempotentResponse) {
|
||||||
|
await req.cacheIdempotentResponse(201, { id: result.insertId });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ id: result.insertId });
|
res.status(201).json({ id: result.insertId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST /offers/:requestId:', error);
|
console.error('Error in POST /offers/:requestId:', error);
|
||||||
|
|
@ -46,7 +52,7 @@ router.post('/:requestId', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/negotiation/:offerId', requireAuth, async (req, res) => {
|
router.post('/negotiation/:offerId', requireAuth, requireIdempotencyKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const offerId = Number(req.params.offerId);
|
const offerId = Number(req.params.offerId);
|
||||||
if (Number.isNaN(offerId)) {
|
if (Number.isNaN(offerId)) {
|
||||||
|
|
@ -68,6 +74,11 @@ router.post('/negotiation/:offerId', requireAuth, async (req, res) => {
|
||||||
|
|
||||||
await pool.query('UPDATE offers SET status = ? WHERE id = ?', ['countered', offerId]);
|
await pool.query('UPDATE offers SET status = ? WHERE id = ?', ['countered', offerId]);
|
||||||
|
|
||||||
|
// Cache the response for idempotent requests
|
||||||
|
if (req.cacheIdempotentResponse) {
|
||||||
|
await req.cacheIdempotentResponse(201, { id: result.insertId });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ id: result.insertId });
|
res.status(201).json({ id: result.insertId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST /offers/negotiation/:offerId:', error);
|
console.error('Error in POST /offers/negotiation/:offerId:', error);
|
||||||
|
|
@ -75,7 +86,7 @@ router.post('/negotiation/:offerId', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/accept/:offerId', requireAuth, async (req, res) => {
|
router.post('/accept/:offerId', requireAuth, requireIdempotencyKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const offerId = Number(req.params.offerId);
|
const offerId = Number(req.params.offerId);
|
||||||
if (Number.isNaN(offerId)) {
|
if (Number.isNaN(offerId)) {
|
||||||
|
|
@ -99,6 +110,11 @@ router.post('/accept/:offerId', requireAuth, async (req, res) => {
|
||||||
[offer.request_id, offer.id, offer.amount_chf]
|
[offer.request_id, offer.id, offer.amount_chf]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cache the response for idempotent requests
|
||||||
|
if (req.cacheIdempotentResponse) {
|
||||||
|
await req.cacheIdempotentResponse(201, { dealId: dealResult.insertId });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({ dealId: dealResult.insertId });
|
res.status(201).json({ dealId: dealResult.insertId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST /offers/accept/:offerId:', error);
|
console.error('Error in POST /offers/accept/:offerId:', error);
|
||||||
|
|
|
||||||
63
docs/adr/001-idempotency.md
Normal file
63
docs/adr/001-idempotency.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# ADR 001: Idempotency for Critical Write Operations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Repeated requests (retries/double clicks) can currently create duplicate offers/acceptances. This is problematic because:
|
||||||
|
|
||||||
|
- Users may accidentally double-click buttons
|
||||||
|
- Network issues might cause requests to timeout and be retried
|
||||||
|
- The system should behave deterministically when handling retries
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We implement idempotency key handling for critical POST endpoints:
|
||||||
|
1. Add `x-idempotency-key` header support to POST endpoints
|
||||||
|
2. Store request results in a database table for replaying responses
|
||||||
|
3. Return cached responses for identical keys
|
||||||
|
4. Apply this to the most critical endpoints:
|
||||||
|
- `/offers/:requestId`
|
||||||
|
- `/offers/negotiation/:offerId`
|
||||||
|
- `/offers/accept/:offerId`
|
||||||
|
- `/help-requests`
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Eliminates duplicate offers/acceptances from retries
|
||||||
|
- Makes critical endpoints behave deterministically on retries
|
||||||
|
- Improves user experience by preventing accidental duplicates
|
||||||
|
- Follows REST best practices for idempotent operations
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Adds complexity to request handling
|
||||||
|
- Requires database storage for idempotency keys
|
||||||
|
- Slight performance overhead for idempotent requests
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
Added `idempotency_keys` table with:
|
||||||
|
- `key`: Unique identifier for the request (VARCHAR)
|
||||||
|
- `response_body`: Cached response data (TEXT)
|
||||||
|
- `created_at`: Timestamp of when the key was first used
|
||||||
|
|
||||||
|
### Middleware
|
||||||
|
Created `requireIdempotencyKey` middleware that:
|
||||||
|
1. Checks for `x-idempotency-key` header in POST requests
|
||||||
|
2. If key exists, checks if it has been used before
|
||||||
|
3. If used before, returns cached response
|
||||||
|
4. If new, stores the request and caches the eventual response
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
Applied to critical write operations:
|
||||||
|
- POST `/offers/:requestId`
|
||||||
|
- POST `/offers/negotiation/:offerId`
|
||||||
|
- POST `/offers/accept/:offerId`
|
||||||
|
- POST `/help-requests`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
Added tests for idempotency behavior in the existing test suite.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue