Skip to content

idempotency key support implemented#282

Merged
OlufunbiIK merged 1 commit intoOlufunbiIK:mainfrom
feyishola:feat/tip-processing-api
Mar 25, 2026
Merged

idempotency key support implemented#282
OlufunbiIK merged 1 commit intoOlufunbiIK:mainfrom
feyishola:feat/tip-processing-api

Conversation

@feyishola
Copy link
Contributor

@feyishola feyishola commented Mar 25, 2026

closes #254

closes #256

Summary by CodeRabbit

  • New Features
    • Added idempotency support for tip submissions. Clients can now include an optional Idempotency-Key header when creating tips. Resubmitting a request with the same key will return the previously created tip instead of creating a duplicate, ensuring safe retry semantics during network failures.

@vercel
Copy link

vercel bot commented Mar 25, 2026

@feyishola is attempting to deploy a commit to the olufunbiik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

Added idempotency key support to the tips API by introducing an optional idempotencyKey column to the database schema, extending the Tip entity and CreateTipDto with the field, updating the controller to accept an Idempotency-Key header, and modifying the service to check for existing tips with the same key and return cached results on replay.

Changes

Cohort / File(s) Summary
Database Migration & Entity
backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts, backend/src/tips/entities/tip.entity.ts
Added optional idempotencyKey column with unique constraint and index to support idempotent requests.
API Input Validation
backend/src/tips/create-tips.dto.ts
Extended CreateTipDto with optional idempotencyKey field (max 128 chars) and OpenAPI documentation.
API Handler & Service
backend/src/tips/tips.controller.ts, backend/src/tips/tips.service.ts
Controller now accepts optional Idempotency-Key header and passes to service; service checks for existing tip with same key and returns cached result on replay, bypassing all downstream processing.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller
    participant Service
    participant Repository
    participant Database

    Client->>Controller: POST /tips with Idempotency-Key header
    Controller->>Controller: Extract & map idempotency-key to DTO
    Controller->>Service: create(userId, createTipDto)
    
    Service->>Repository: findOne(idempotencyKey)
    Repository->>Database: SELECT * FROM tips WHERE idempotencyKey = ?
    
    alt Idempotency Key Match Found
        Database-->>Repository: Return existing Tip
        Repository-->>Service: Return cached Tip
        Service->>Service: Log replay message
        Service-->>Controller: Return existing Tip (no processing)
    else No Match - New Request
        Service->>Service: Validate & process tip
        Service->>Service: Stellar transaction checks
        Service->>Service: Moderation & fees
        Service->>Repository: create(tip with idempotencyKey)
        Repository->>Database: INSERT INTO tips (...)
        Database-->>Repository: Return persisted Tip
        Repository-->>Service: Return new Tip
        Service->>Service: Emit events & notifications
        Service-->>Controller: Return new Tip
    end
    
    Controller-->>Client: Tip response (200 OK)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 Hops of wisdom, keys so fine,
No more duplicates in the line!
Idempotent tips, we replay with grace,
Retries find home in their rightful place.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue #256 requirements are fully met (idempotency key persistence and replay). Issue #254 (playlist collaboration conflicts) has no implementation in this changeset. Either implement playlist collaboration conflict indicators from #254 or remove it from linked issues if not part of this PR scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and concisely describes the main change: implementing idempotency key support, which aligns with the primary objective from issue #256.
Out of Scope Changes check ✅ Passed All changes are focused on idempotency key support for tips service. No unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
backend/src/tips/entities/tip.entity.ts (1)

50-52: Redundant @Index() decorator when unique: true is already set.

The unique: true constraint on the column automatically creates a unique index in the database. The explicit @Index() decorator creates an additional non-unique index, which is redundant and wastes storage.

Suggested fix
-  `@Column`({ length: 128, nullable: true, unique: true })
-  `@Index`()
-  idempotencyKey?: string;
+  `@Column`({ length: 128, nullable: true, unique: true })
+  idempotencyKey?: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tips/entities/tip.entity.ts` around lines 50 - 52, The
idempotencyKey column currently has both `@Column`({ length: 128, nullable: true,
unique: true }) and an explicit `@Index`() which is redundant; remove the `@Index`()
decorator for the idempotencyKey property so the unique: true on the Column
creates the single unique index and avoids creating an extra non-unique index
(locate the idempotencyKey field in the Tip entity to apply this change).
backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts (1)

5-23: Redundant unique index creation.

The column is created with isUnique: true (line 12), which already creates a unique constraint on the column. Creating an additional unique index via createIndex (lines 16-23) is redundant and creates duplicate enforcement of the same constraint.

Remove either the isUnique: true from the column definition or the separate index creation.

Option 1: Keep column-level constraint only
     await queryRunner.addColumn(
       'tips',
       new TableColumn({
         name: 'idempotencyKey',
         type: 'varchar',
         length: '128',
         isNullable: true,
         isUnique: true,
       }),
     );
-
-    await queryRunner.createIndex(
-      'tips',
-      new TableIndex({
-        name: 'IDX_tips_idempotencyKey',
-        columnNames: ['idempotencyKey'],
-        isUnique: true,
-      }),
-    );
   }

   public async down(queryRunner: QueryRunner): Promise<void> {
-    await queryRunner.dropIndex('tips', 'IDX_tips_idempotencyKey');
     await queryRunner.dropColumn('tips', 'idempotencyKey');
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts` around lines 5 -
23, The migration currently adds a unique constraint twice: once via the
TableColumn option isUnique: true on the idempotencyKey column (in the
queryRunner.addColumn call creating new TableColumn with name 'idempotencyKey')
and again by creating a unique index ('IDX_tips_idempotencyKey') with
queryRunner.createIndex/new TableIndex. Remove the redundancy by deleting the
separate createIndex block (queryRunner.createIndex(... new TableIndex { name:
'IDX_tips_idempotencyKey', ... })) so the single column-level unique constraint
remains; alternatively, if you prefer an explicit index, set isUnique: false on
the TableColumn and keep the createIndex—ensure the up/down migration changes
remain consistent with whichever option you choose.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/tips/tips.controller.ts`:
- Around line 69-72: The idempotencyKeyHeader is being assigned to
createTipDto.idempotencyKey after the DTO has already passed ValidationPipe, so
the header bypasses the `@MaxLength`(128) check; fix by validating the header
before or as part of DTO validation: either set createTipDto.idempotencyKey from
idempotencyKeyHeader prior to running ValidationPipe (so class-validator
enforces `@MaxLength`(128)) or perform an explicit check on idempotencyKeyHeader
(e.g., if (idempotencyKeyHeader.length > 128) throw new
BadRequestException(...)) before assigning it to createTipDto; reference
createTipDto, idempotencyKeyHeader, ValidationPipe, and the `@MaxLength`(128)
constraint when making the change.

In `@backend/src/tips/tips.service.ts`:
- Around line 71-81: Concurrent requests using the same idempotencyKey can both
pass the tipRepository.findOne check and then one insert will succeed while the
other throws a QueryFailedError (unique constraint violation); modify the
create/save path that persists the tip (where you call this.tipRepository.save /
insert after checking idempotencyKey) to wrap the save in a try-catch, detect
the DB unique-constraint error (QueryFailedError or specific SQL error code),
and on that error re-query tipRepository.findOne({ where: { idempotencyKey } })
and return the existing record instead of letting the exception bubble; apply
the same try-catch pattern to the other similar block referenced at lines
182-185.
- Around line 70-80: The idempotency lookup returns any record matching
idempotencyKey and can leak tip data across users; update the logic in the
idempotency block (the call to this.tipRepository.findOne and the subsequent use
of existing/return existing) to verify ownership before replaying: either
include the current user's identifier in the findOne query (e.g., where: {
idempotencyKey, fromUser: currentUserId }) or, if you can't change the query,
check existing.fromUser === currentUserId and only log/return existing when they
match; alternatively plan a schema change to enforce a compound unique
constraint on (fromUser, idempotencyKey) to make the key per-user.
- Around line 67-68: The Tip entity's required fromUser field is missing when
creating a new Tip in the create method; update the tipRepository.create(...)
call inside the async create(userId: string, createTipDto: CreateTipDto):
Promise<Tip> function to include fromUser: userId (alongside senderAddress,
artistId, trackId, stellarTxHash, message, idempotencyKey) so the created Tip
has the required fromUser relation populated.

---

Nitpick comments:
In `@backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts`:
- Around line 5-23: The migration currently adds a unique constraint twice: once
via the TableColumn option isUnique: true on the idempotencyKey column (in the
queryRunner.addColumn call creating new TableColumn with name 'idempotencyKey')
and again by creating a unique index ('IDX_tips_idempotencyKey') with
queryRunner.createIndex/new TableIndex. Remove the redundancy by deleting the
separate createIndex block (queryRunner.createIndex(... new TableIndex { name:
'IDX_tips_idempotencyKey', ... })) so the single column-level unique constraint
remains; alternatively, if you prefer an explicit index, set isUnique: false on
the TableColumn and keep the createIndex—ensure the up/down migration changes
remain consistent with whichever option you choose.

In `@backend/src/tips/entities/tip.entity.ts`:
- Around line 50-52: The idempotencyKey column currently has both `@Column`({
length: 128, nullable: true, unique: true }) and an explicit `@Index`() which is
redundant; remove the `@Index`() decorator for the idempotencyKey property so the
unique: true on the Column creates the single unique index and avoids creating
an extra non-unique index (locate the idempotencyKey field in the Tip entity to
apply this change).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bb685032-12db-4923-88c2-96d8c5590648

📥 Commits

Reviewing files that changed from the base of the PR and between 196ed8d and 717a1cc.

📒 Files selected for processing (5)
  • backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts
  • backend/src/tips/create-tips.dto.ts
  • backend/src/tips/entities/tip.entity.ts
  • backend/src/tips/tips.controller.ts
  • backend/src/tips/tips.service.ts

Comment on lines +69 to +72
// Header takes precedence over body field; merge into DTO
if (idempotencyKeyHeader) {
createTipDto.idempotencyKey = idempotencyKeyHeader;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Header value bypasses DTO validation.

The idempotencyKeyHeader is assigned to createTipDto.idempotencyKey after the DTO has already been validated by the ValidationPipe. This means the header value bypasses @MaxLength(128) validation—a header longer than 128 characters will pass through and could cause a database error or truncation.

Suggested fix: Add explicit validation
     // Header takes precedence over body field; merge into DTO
     if (idempotencyKeyHeader) {
+      if (idempotencyKeyHeader.length > 128) {
+        throw new BadRequestException('Idempotency-Key header must not exceed 128 characters');
+      }
       createTipDto.idempotencyKey = idempotencyKeyHeader;
     }
📝 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
// Header takes precedence over body field; merge into DTO
if (idempotencyKeyHeader) {
createTipDto.idempotencyKey = idempotencyKeyHeader;
}
// Header takes precedence over body field; merge into DTO
if (idempotencyKeyHeader) {
if (idempotencyKeyHeader.length > 128) {
throw new BadRequestException('Idempotency-Key header must not exceed 128 characters');
}
createTipDto.idempotencyKey = idempotencyKeyHeader;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tips/tips.controller.ts` around lines 69 - 72, The
idempotencyKeyHeader is being assigned to createTipDto.idempotencyKey after the
DTO has already passed ValidationPipe, so the header bypasses the
`@MaxLength`(128) check; fix by validating the header before or as part of DTO
validation: either set createTipDto.idempotencyKey from idempotencyKeyHeader
prior to running ValidationPipe (so class-validator enforces `@MaxLength`(128)) or
perform an explicit check on idempotencyKeyHeader (e.g., if
(idempotencyKeyHeader.length > 128) throw new BadRequestException(...)) before
assigning it to createTipDto; reference createTipDto, idempotencyKeyHeader,
ValidationPipe, and the `@MaxLength`(128) constraint when making the change.

Comment on lines 67 to +68
async create(userId: string, createTipDto: CreateTipDto): Promise<Tip> {
const { artistId, trackId, stellarTxHash, message } = createTipDto;
const { artistId, trackId, stellarTxHash, message, idempotencyKey } = createTipDto;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if fromUser is set anywhere during tip creation
ast-grep --pattern 'fromUser: $_'

Repository: OlufunbiIK/tip-tune

Length of output: 45


🏁 Script executed:

fd -e "\.ts$" tips | grep -E "(entity|service)" | head -20

Repository: OlufunbiIK/tip-tune

Length of output: 45


🏁 Script executed:

cat -n backend/src/tips/tips.entity.ts | head -100

Repository: OlufunbiIK/tip-tune

Length of output: 126


🏁 Script executed:

cat -n backend/src/tips/tips.service.ts | sed -n '60,90p'

Repository: OlufunbiIK/tip-tune

Length of output: 1363


🏁 Script executed:

cat -n backend/src/tips/tips.service.ts | sed -n '160,185p'

Repository: OlufunbiIK/tip-tune

Length of output: 985


🏁 Script executed:

find . -type f -name "*entity*" | grep -i tip

Repository: OlufunbiIK/tip-tune

Length of output: 152


🏁 Script executed:

find . -type f -name "*.ts" | xargs grep -l "fromUser" | head -10

Repository: OlufunbiIK/tip-tune

Length of output: 426


🏁 Script executed:

rg "class Tip" -A 30

Repository: OlufunbiIK/tip-tune

Length of output: 21374


🏁 Script executed:

cat -n backend/src/tips/entities/tip.entity.ts

Repository: OlufunbiIK/tip-tune

Length of output: 4776


Set fromUser field during tip creation.

The Tip entity has a required fromUser field (line 70), but it's not being set in the tipRepository.create() call (lines 168-183). The userId parameter is currently only used to fetch the user and set senderAddress, leaving fromUser undefined. Add fromUser: userId to the repository create call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tips/tips.service.ts` around lines 67 - 68, The Tip entity's
required fromUser field is missing when creating a new Tip in the create method;
update the tipRepository.create(...) call inside the async create(userId:
string, createTipDto: CreateTipDto): Promise<Tip> function to include fromUser:
userId (alongside senderAddress, artistId, trackId, stellarTxHash, message,
idempotencyKey) so the created Tip has the required fromUser relation populated.

Comment on lines +70 to +80
// --- Idempotency key check: replay the original response if key already seen ---
if (idempotencyKey) {
const existing = await this.tipRepository.findOne({
where: { idempotencyKey },
});
if (existing) {
this.logger.log(
`Idempotency replay for key=${idempotencyKey}, tipId=${existing.id}`,
);
return existing;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Security: Idempotency replay leaks tip data across users.

The lookup queries only by idempotencyKey without verifying ownership. If User A creates a tip with key "abc", and User B later submits a request with the same key, User B receives User A's tip record—exposing senderAddress, amount, message, and other sensitive fields.

Either scope the idempotency key per-user (requires schema change to a compound unique constraint on (fromUser, idempotencyKey)) or validate ownership before returning the cached response.

Minimal fix: Validate ownership before replay
     if (idempotencyKey) {
       const existing = await this.tipRepository.findOne({
         where: { idempotencyKey },
       });
       if (existing) {
+        // Verify the cached tip belongs to the requesting user
+        if (existing.fromUser !== userId) {
+          throw new ConflictException(
+            'Idempotency key is already in use by another request',
+          );
+        }
         this.logger.log(
           `Idempotency replay for key=${idempotencyKey}, tipId=${existing.id}`,
         );
         return existing;
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tips/tips.service.ts` around lines 70 - 80, The idempotency
lookup returns any record matching idempotencyKey and can leak tip data across
users; update the logic in the idempotency block (the call to
this.tipRepository.findOne and the subsequent use of existing/return existing)
to verify ownership before replaying: either include the current user's
identifier in the findOne query (e.g., where: { idempotencyKey, fromUser:
currentUserId }) or, if you can't change the query, check existing.fromUser ===
currentUserId and only log/return existing when they match; alternatively plan a
schema change to enforce a compound unique constraint on (fromUser,
idempotencyKey) to make the key per-user.

Comment on lines +71 to +81
if (idempotencyKey) {
const existing = await this.tipRepository.findOne({
where: { idempotencyKey },
});
if (existing) {
this.logger.log(
`Idempotency replay for key=${idempotencyKey}, tipId=${existing.id}`,
);
return existing;
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Race condition: Concurrent requests with the same new key cause unhandled exception.

Two concurrent requests with the same (new) idempotencyKey can both pass the findOne check (TOCTOU), then both attempt to insert. One succeeds; the other throws a QueryFailedError (unique constraint violation) that bubbles up as a 500 error instead of returning the already-created tip.

Wrap the save in a try-catch to handle the duplicate key error gracefully.

Suggested fix: Catch unique constraint violation and retry lookup
-    const savedTip = await this.tipRepository.save(newTip);
+    let savedTip: Tip;
+    try {
+      savedTip = await this.tipRepository.save(newTip);
+    } catch (error: any) {
+      // Handle race condition: another request inserted the same idempotencyKey
+      if (
+        idempotencyKey &&
+        error.code === '23505' // PostgreSQL unique violation
+      ) {
+        const existing = await this.tipRepository.findOne({
+          where: { idempotencyKey },
+        });
+        if (existing && existing.fromUser === userId) {
+          this.logger.log(
+            `Idempotency replay (race recovery) for key=${idempotencyKey}, tipId=${existing.id}`,
+          );
+          return existing;
+        }
+        throw new ConflictException('Idempotency key collision');
+      }
+      throw error;
+    }

Also applies to: 182-185

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tips/tips.service.ts` around lines 71 - 81, Concurrent requests
using the same idempotencyKey can both pass the tipRepository.findOne check and
then one insert will succeed while the other throws a QueryFailedError (unique
constraint violation); modify the create/save path that persists the tip (where
you call this.tipRepository.save / insert after checking idempotencyKey) to wrap
the save in a try-catch, detect the DB unique-constraint error (QueryFailedError
or specific SQL error code), and on that error re-query tipRepository.findOne({
where: { idempotencyKey } }) and return the existing record instead of letting
the exception bubble; apply the same try-catch pattern to the other similar
block referenced at lines 182-185.

@OlufunbiIK
Copy link
Owner

LGTM

@OlufunbiIK OlufunbiIK merged commit 49df896 into OlufunbiIK:main Mar 25, 2026
2 of 3 checks 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.

Idempotent Tip Processing API Playlist Collaboration Conflict Indicators

2 participants