Skip to content

Feat/idempotency key - #192

Merged
meshackyaro merged 7 commits into
trustflow-protocol:mainfrom
Grandida-Projects:feat/idempotency-key
Aug 22, 2026
Merged

meshackyaro merged 7 commits into
trustflow-protocol:mainfrom
Grandida-Projects:feat/idempotency-key

Conversation

@Wilfred007

Copy link
Copy Markdown
Contributor

Description

Add Redis-backed idempotency key support for mutating POST endpoints (POST /gigs and POST /escrows). Clients can send an Idempotency-Key header (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

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📝 Documentation update
  • 🔧 Configuration change
  • ♻️ Code refactoring
  • ⚡ Performance improvement
  • ✅ Test update

Component

  • Backend (Node.js API)
  • Frontend (Next.js UI)
  • Smart Contract (Soroban/Rust)
  • SDK
  • Infrastructure/DevOps
  • Documentation

Changes Made

  • Add @Idempotent() decorator and IdempotencyKeyService backed by Redis with SHA-256 body hashing and 24h TTL (src/common/idempotency/)
  • Add IdempotencyKeyInterceptor registered globally via APP_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 present
  • Opt POST /gigs and POST /escrows into idempotency with @Idempotent() decorator and @ApiHeader Swagger annotations
  • Register IdempotencyModule in AppModule wired to the existing REDIS_CLIENT
  • Graceful degradation: when Redis is unavailable, all requests pass through unchanged (same pattern as GigService and NonceStoreService)

Testing

Manual Testing

  • Tested locally
  • Tested in development environment
  • Tested edge cases

Automated Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests passing locally

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 errors
  • IdempotencyKeyInterceptor: passthrough without header, cache replay on retry, 422 on body mismatch, ignored on non-decorated endpoints, graceful pass-through when Redis is null

Integration tests (idempotency.integration.spec.ts — 6 tests):

  • Gig-like endpoint: single record on duplicate key+body, 422 on key reuse with different body, separate records without header
  • Escrow-like endpoint: same three scenarios

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (Swagger @ApiHeader annotations)
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

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 via APP_INTERCEPTOR but 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-Key header 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:

  • The interceptor's interaction with the existing RateLimitGuard (both are global, order matters)
  • Whether the 24h TTL and SHA-256 hashing approach aligns with your Redis usage patterns
  • The as any cast in the test mock for IdempotencyKeyService — this is intentional since the mock only needs get/set

Closes #183

“Wilfred007” added 4 commits August 18, 2026 22:15
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.
“Wilfred007” added 2 commits August 19, 2026 18:00
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.
@meshackyaro

Copy link
Copy Markdown
Contributor

Description

Add Redis-backed idempotency key support for mutating POST endpoints (POST /gigs and POST /escrows). Clients can send an Idempotency-Key header (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

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📝 Documentation update
  • 🔧 Configuration change
  • ♻️ Code refactoring
  • ⚡ Performance improvement
  • ✅ Test update

Component

  • Backend (Node.js API)
  • Frontend (Next.js UI)
  • Smart Contract (Soroban/Rust)
  • SDK
  • Infrastructure/DevOps
  • Documentation

Changes Made

  • Add @Idempotent() decorator and IdempotencyKeyService backed by Redis with SHA-256 body hashing and 24h TTL (src/common/idempotency/)
  • Add IdempotencyKeyInterceptor registered globally via APP_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 present
  • Opt POST /gigs and POST /escrows into idempotency with @Idempotent() decorator and @ApiHeader Swagger annotations
  • Register IdempotencyModule in AppModule wired to the existing REDIS_CLIENT
  • Graceful degradation: when Redis is unavailable, all requests pass through unchanged (same pattern as GigService and NonceStoreService)

Testing

Manual Testing

  • Tested locally
  • Tested in development environment
  • Tested edge cases

Automated Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests passing locally

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 errors
  • IdempotencyKeyInterceptor: passthrough without header, cache replay on retry, 422 on body mismatch, ignored on non-decorated endpoints, graceful pass-through when Redis is null

Integration tests (idempotency.integration.spec.ts — 6 tests):

  • Gig-like endpoint: single record on duplicate key+body, 422 on key reuse with different body, separate records without header
  • Escrow-like endpoint: same three scenarios

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (Swagger @ApiHeader annotations)
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

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 via APP_INTERCEPTOR but 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-Key header 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:

  • The interceptor's interaction with the existing RateLimitGuard (both are global, order matters)
  • Whether the 24h TTL and SHA-256 hashing approach aligns with your Redis usage patterns
  • The as any cast in the test mock for IdempotencyKeyService — this is intentional since the mock only needs get/set

Closes #183

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.interceptor.ts — Add or clarify the atomicity behavior for concurrent requests: ensure the Redis operations used make storing/claiming atomic (SET with NX, or use a Lua script to claim and store the response atomically). If the implementation does not use an atomic claim, concurrent duplicate requests can cause duplicate resource creation.
src/app.module.ts (or wherever IdempotencyModule is registered) — Confirm/instrument fallback path when REDIS_CLIENT is null/unavailable: add unit/integration test coverage that simulates Redis being down and asserts the handler still runs and no intercept blocking occurs. The PR says graceful degradation exists, but add a test that explicitly simulates Redis connection failure and verifies the behavior.
src/common/idempotency/idempotency-key.interceptor.ts — When storing the cached response, ensure both status code and headers that matter (e.g., Location) are saved and replayed exactly; otherwise clients may receive incomplete replays for responses that rely on headers.
Possible improvements (non-blocking, but strongly recommended)

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.
src/common/idempotency/idempotency-key.service.ts or interceptor.ts — Add metrics (Prometheus counters) and structured logs for: idempotency cache hit, miss, store success, Redis errors, and 422 key-mismatch events. This will help debugging and monitoring in production.
README / API docs — Add a short note to the backend docs describing idempotency behavior (expected client header format, semantics when key is replayed with a different body -> 422, and TTL), plus guidance for clients on how to generate keys.
src/common/idempotency/idempotency-key.interceptor.ts — If your endpoints may stream responses or use large payloads, document or handle the memory overhead of caching full response bodies (consider size limits on cached responses).
tests/idempotency.integration.spec.ts — Add a concurrency test that sends N parallel identical requests and asserts exactly one resource is created and subsequent requests return cached result. This ensures the implementation handles race conditions.
Minor / stylistic suggestions

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.
Add a short E2E sample in tests demonstrating client retry with the same Idempotency-Key (this may be covered already, but it's valuable as a documented example).

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.

@Wilfred007

Copy link
Copy Markdown
Contributor Author

Kindly confirm fix @meshackyaro

@meshackyaro meshackyaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 RateLimitGuard and 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!

@meshackyaro
meshackyaro merged commit 5902021 into trustflow-protocol:main Aug 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add idempotency-key support to gig/escrow creation endpoints

2 participants