Feat/idempotency key - #192
Conversation
Introduce IdempotencyKeyService with SHA-256 body hashing and bounded-TTL Redis storage for caching (endpoint, key) -> response mappings. The @idempotent() decorator marks endpoints opt-in. Includes the IdempotencyModule wired to the existing REDIS_CLIENT and an index barrel export.
IdempotencyKeyInterceptor is registered via APP_INTERCEPTOR so it applies to every route. Endpoints decorated with @idempotent() are intercepted: the interceptor checks for an Idempotency-Key header, replays the cached response on a matching key+body hit, and returns 422 when the same key is reused with a different request payload. Requests without the header pass through unchanged.
Add @idempotent() to the gig creation handler and @ApiHeader for the Idempotency-Key in the Swagger spec so API consumers know retries are safe when they supply a unique key.
Add @idempotent() to the escrow creation handler and @ApiHeader for the Idempotency-Key in the Swagger spec.
d3b0e02 to
b76f272
Compare
Wire the idempotency module into the root module so the global interceptor is active for all decorated endpoints.
Unit tests cover: IdempotencyKeyService (hashBody, lookup, store, graceful degradation), IdempotencyKeyInterceptor (passthrough, cache replay, 422 on body mismatch, non-decorated endpoints, Redis unavailable). Integration tests cover: gig-like and escrow-like endpoints with supertest verifying single-record creation on retry, 422 on key reuse with different body, and backward compatibility when no Idempotency-Key header is sent.
Approve with minor changes — this is a well-scoped, well-tested feature and this will be ready to merge after addressing a few small but important changes (namespacing, a concurrency test, and small observability/config tweaks). Critical issues to address before merging src/common/idempotency/idempotency-key.service.ts — Ensure the Redis key uses endpoint scoping (for example, include HTTP method + route or a handler identifier when generating the Redis key). Currently storing by only the Idempotency-Key + body-hash can allow a key created for /gigs to be reused against /escrows (or vice versa); include the route/method when composing the cache key so idempotency is scoped to the endpoint. src/common/idempotency/idempotency-key.service.ts — Make TTL configurable via environment variable (e.g., IDEMPOTENCY_KEY_TTL_SECONDS or similar) with a default of 24h so operators can tune retention per workload. Prefer clear variable names for stored response shape (status, body, headers) and include a version field in the stored value so future format changes can be handled gracefully. Closing / final recommendation This PR is a solid, well-tested addition that follows the repository’s existing patterns (decorator + global interceptor approach). Address the critical items above (namespacing the cache key and ensuring atomicity for concurrent requests), add the suggested tests and a small amount of observability/config, and it should be ready to merge. |
|
Kindly confirm fix @meshackyaro |
meshackyaro
left a comment
There was a problem hiding this comment.
Approved — Excellent work, Wilfred!
This PR delivers a robust, production-ready idempotency key system that significantly improves the resilience of our mutating endpoints. Combined with your follow-up commit (5e4722c), the implementation is comprehensive and handles subtle concurrency edge cases that most idempotency implementations miss.
What stands out:
Architecture & Design
- Clean integration with NestJS patterns — the global interceptor + decorator approach mirrors our existing
RateLimitGuardand feels natural in the codebase - Two-phase claim-finalize protocol (in 5e4722c) is a sophisticated solution that guarantees exactly-once semantics under concurrent requests
- Thoughtful error recovery — failed claims are released immediately so retries aren't artificially blocked by a 24h TTL
Code Quality
- Well-commented, especially the interceptor logic and the atomic claim semantics
- Comprehensive test coverage: unit tests for service/interceptor logic + integration tests for real-world scenarios (deduplication, body mismatch, concurrent races, cross-endpoint namespacing)
- All 383 existing tests still pass; no regressions introduced
- Graceful degradation throughout — Redis unavailability never blocks requests, it just disables the optimization
Observability & Operations
- Metrics at all critical paths (cache hits/misses, conflicts, errors) enable production debugging and alerting
- Configuration flexibility (IDEMPOTENCY_KEY_TTL_SECONDS env var) lets different environments tune retention
- Clear API documentation with client usage examples and a behavior table
Backward Compatibility
- Fully backward compatible — requests without the header work exactly as before
- No breaking changes to any endpoints
Minor polish in the follow-up commit:
- 409 Conflict response when concurrent identical requests race (instead of silent re-execution)
- Header caching and replay for Location/custom headers
- Versioned record schema for forward compatibility
Ready to merge
Everything is green. No blockers, no ambiguity. Ship it!
Description
Add Redis-backed idempotency key support for mutating POST endpoints (
POST /gigsandPOST /escrows). Clients can send anIdempotency-Keyheader (e.g. a UUID) to guarantee that retries after timeouts, flaky connections, or double-taps produce exactly one record. A replayed key with the same body returns the cached response; a key reused with a different body returns 422. Requests without the header behave exactly as they do today.Closes #<issue_number>
Type of Change
Component
Changes Made
@Idempotent()decorator andIdempotencyKeyServicebacked by Redis with SHA-256 body hashing and 24h TTL (src/common/idempotency/)IdempotencyKeyInterceptorregistered globally viaAPP_INTERCEPTOR— intercepts only@Idempotent()endpoints, replays cached responses on matching key+body, returns 422 on key reuse with different payload, and passes through when no header is presentPOST /gigsandPOST /escrowsinto idempotency with@Idempotent()decorator and@ApiHeaderSwagger annotationsIdempotencyModuleinAppModulewired to the existingREDIS_CLIENTGigServiceandNonceStoreService)Testing
Manual Testing
Automated Testing
Test results: 38 suites passed, 383 tests passed, 0 failures
Unit tests (
idempotency-key.interceptor.spec.ts— 16 tests):IdempotencyKeyService: hashBody consistency/differentiation/null handling, lookup with/without Redis, store with TTL, graceful degradation on Redis errorsIdempotencyKeyInterceptor: passthrough without header, cache replay on retry, 422 on body mismatch, ignored on non-decorated endpoints, graceful pass-through when Redis is nullIntegration tests (
idempotency.integration.spec.ts— 6 tests):Checklist
@ApiHeaderannotations)Screenshots/Recordings (if applicable)
N/A — backend-only change, Swagger docs updated in-place.
Additional Notes
Architecture: Follows the exact pattern of the existing
RateLimitGuard+@SkipRateLimit()decorator. The interceptor is registered globally viaAPP_INTERCEPTORbut only activates on handlers decorated with@Idempotent(), so new endpoints can opt in by adding one decorator.Backward compatibility: Fully backward compatible — requests without the
Idempotency-Keyheader are unaffected. Redis availability is not required; the feature degrades gracefully to pass-through.Tradeoffs: Cached responses are stored as JSON in Redis with a 24h TTL. The body hash uses SHA-256 for compact storage. This means very large response bodies will consume Redis memory proportional to their size — acceptable for the current response shapes but worth monitoring if response sizes grow significantly.
Reviewer Notes
Please focus on:
RateLimitGuard(both are global, order matters)as anycast in the test mock forIdempotencyKeyService— this is intentional since the mock only needsget/setCloses #183