Skip to content

fix: atomically check and consume credits on submission#1400

Open
mitgajera wants to merge 1 commit into
SuperteamDAO:stagingfrom
mitgajera:fix/credit-race-condition
Open

fix: atomically check and consume credits on submission#1400
mitgajera wants to merge 1 commit into
SuperteamDAO:stagingfrom
mitgajera:fix/credit-race-condition

Conversation

@mitgajera

@mitgajera mitgajera commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Wraps the credit check, submission creation, and credit consumption in a single Redis lock per user, making the three previously separate operations atomic.

Before this fix, concurrent requests could all pass the credit check before any credit was consumed, allowing unlimited submissions with a single credit.

Where should the reviewer start?

src/pages/api/submission/create.ts - the withRedisLock block at line 128.

How should this be manually tested?

  1. As a user with 1 credit, fire multiple concurrent POST requests to /api/submission/create
  2. Only 1 should succeed with 200 - the rest return 429
  3. Verify only 1 submission in DB and credit balance is 0

Any background context you want to provide?

canUserSubmit() and consumeCredit() were two separate DB calls with no lock between them. The fix uses the existing withRedisLock utility (already used in the grant tranche flow) with a 30s TTL keyed to userId. Hackathon and Pro submissions are unaffected.

What are the relevant issues?

Internal security.

Screenshots

N/A

Summary by CodeRabbit

  • Bug Fixes
    • Improved submission handling to prevent concurrent duplicate submissions with appropriate conflict messaging.
    • Enhanced credit availability verification and consumption for standard submissions.

@vercel

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown

@mitgajera is attempting to deploy a commit to the Superteam Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds Redis-based distributed locking to the submission creation API route to serialize credit availability checks and consumption for regular (non-hackathon, non-pro) listings. A 30-second lock gates the credit validation and deduction, while hackathon and pro submissions bypass this mechanism. Lock contention returns HTTP 429; other errors are logged and mapped to appropriate status codes.

Changes

Redis-based Submission Locking for Credit Management

Layer / File(s) Summary
Lock dependencies and submission locking logic
src/pages/api/submission/create.ts
Imports LockNotAcquiredError and withRedisLock. Core submission flow for non-hackathon, non-pro listings now checks credit availability, creates the submission, and consumes credits all within a 30-second Redis lock to prevent concurrent credit overconsumption.
Lock contention and error response mapping
src/pages/api/submission/create.ts
Error handler catches LockNotAcquiredError and returns HTTP 429 with a retry-later message. Other errors are logged with user context and mapped to HTTP 400 (validation errors) or 403 (other errors).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • SuperteamDAO/earn#1277: Both PRs implement Redis-based distributed locking using withRedisLock/LockNotAcquiredError to gate a concurrency-sensitive critical section (main PR: submission credit consumption; referenced PR: winner announcement), including dedicated lock-contention handling.

Suggested reviewers

  • a20hek

Poem

🔐 A rabbit locks tight with Redis's might,
Credit checks dance in synchronized flight,
No double spending in this fearless domain,
When locks collide, we retry again! 🐰⚡

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: implementing atomic credit checking and consumption on submission via Redis locking to prevent race conditions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 197d5814ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

logger.info(`Consumed 1 credit from user ${userId} for submission`);
return submission;
},
{ ttlSeconds: 30 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a lock TTL that covers slow submissions

When a regular submission takes longer than 30 seconds here (for example during slow Prisma calls or a transient DB/Redis slowdown), this Redis lock expires while the callback is still running. A second request from the same user can then acquire the lock, see the pre-consumption balance, create another submission, and both callbacks can consume a credit, so the check/consume is no longer atomic under exactly the contention this change is meant to protect against. The shared helper defaults to a 300-second TTL elsewhere, so this shorter TTL should either be extended or the credit check/create/consume should be protected by a DB transaction/conditional update that cannot expire mid-flight.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/pages/api/submission/create.ts (1)

193-197: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop classifying unknown failures as 403.

Line 193 assumes validation failures contain the word Validation, but createSubmission() throws the raw safeParse JSON, so malformed requests will miss that branch. Everything else—Prisma failures, Redis failures, queueEmail() failures—also gets turned into a 403 with the internal message echoed back to the client. Map known domain errors explicitly and fall back to a generic 500 for everything else. As per coding guidelines, "Use HTTP status codes: 200 for success, 400 for validation errors, 401 for unauthorized (no auth token), 403 for forbidden (insufficient permissions), 404 for not found, 500 for internal server error."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/api/submission/create.ts` around lines 193 - 197, The error
handling currently classifies most failures as 403 and echoes internal messages;
change the response in the catch to explicitly map known domain errors (e.g.,
validation errors from createSubmission()/safeParse -> 400, missing/invalid auth
-> 401, permission checks -> 403, not found cases -> 404) and for all other
unexpected exceptions (Prisma, Redis, queueEmail, etc.) return a 500 with a
generic message. Inspect the thrown value from createSubmission() (which may be
the raw safeParse error), detect its shape/type to decide 400, and avoid sending
raw error.message to clients—log the full error internally and return only a
safe client-facing message (e.g., "Internal server error" for 500). Ensure you
update the response construction in this handler (the block using
res.status(...).json({...}) and references to userId) to implement these
mappings and internal logging.
🧹 Nitpick comments (1)
src/pages/api/submission/create.ts (1)

184-190: ⚡ Quick win

Downgrade handled lock contention logging from error to warn.

This branch intentionally returns 429, but Line 184 logs every lock miss as an error before the LockNotAcquiredError check runs. That will pollute error monitoring during normal contention. Move the generic logger.error below the lock branch, or log this case at warn instead. As per coding guidelines, "Use debug logs for request data, info logs for successful operations, warning logs for handled issues, and error logs for exceptions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/api/submission/create.ts` around lines 184 - 190, The current code
logs every submission lock miss as an error (logger.error) before checking for
LockNotAcquiredError; change this so handled lock contention is not an error:
either move the existing logger.error call below the LockNotAcquiredError branch
or change the log level to logger.warn when error instanceof
LockNotAcquiredError, and keep the 429 response block
(res.status(429).json(...)) unchanged; ensure only unexpected exceptions still
call logger.error while LockNotAcquiredError uses logger.warn to avoid polluting
error monitoring.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/pages/api/submission/create.ts`:
- Around line 138-145: The submission is created by createSubmission(...) then
consumeCredit(...) is called separately, so if consumeCredit fails the
submission remains committed; fix by making the two operations atomic: either
implement a single transactional operation (e.g., add a new function
createSubmissionAndConsumeCredit(userId, listingId, payload, listing) that wraps
the DB insert and credit decrement in one DB transaction) or add compensating
cleanup immediately on failure (catch errors from consumeCredit(submission.id)
and call deleteSubmission(submission.id) to roll back the inserted submission,
logging and surfacing any errors). Modify the code path around createSubmission
and consumeCredit to use the transactional helper or the compensating delete to
ensure both mutations succeed or neither do.

---

Outside diff comments:
In `@src/pages/api/submission/create.ts`:
- Around line 193-197: The error handling currently classifies most failures as
403 and echoes internal messages; change the response in the catch to explicitly
map known domain errors (e.g., validation errors from
createSubmission()/safeParse -> 400, missing/invalid auth -> 401, permission
checks -> 403, not found cases -> 404) and for all other unexpected exceptions
(Prisma, Redis, queueEmail, etc.) return a 500 with a generic message. Inspect
the thrown value from createSubmission() (which may be the raw safeParse error),
detect its shape/type to decide 400, and avoid sending raw error.message to
clients—log the full error internally and return only a safe client-facing
message (e.g., "Internal server error" for 500). Ensure you update the response
construction in this handler (the block using res.status(...).json({...}) and
references to userId) to implement these mappings and internal logging.

---

Nitpick comments:
In `@src/pages/api/submission/create.ts`:
- Around line 184-190: The current code logs every submission lock miss as an
error (logger.error) before checking for LockNotAcquiredError; change this so
handled lock contention is not an error: either move the existing logger.error
call below the LockNotAcquiredError branch or change the log level to
logger.warn when error instanceof LockNotAcquiredError, and keep the 429
response block (res.status(429).json(...)) unchanged; ensure only unexpected
exceptions still call logger.error while LockNotAcquiredError uses logger.warn
to avoid polluting error monitoring.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 275f0793-72f0-4f1b-9291-864c3c6e3dd9

📥 Commits

Reviewing files that changed from the base of the PR and between ff604ba and 197d581.

📒 Files selected for processing (1)
  • src/pages/api/submission/create.ts

Comment on lines +138 to +145
const submission = await createSubmission(
userId as string,
listingId,
{ link, tweet, otherInfo, eligibilityAnswers, ask, telegram },
listing,
);
await consumeCredit(userId, submission.id);
logger.info(`Consumed 1 credit from user ${userId} for submission`);

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep submission persistence and credit consumption in one failure domain.

If consumeCredit() fails after Line 138 has already inserted the submission, this request returns an error but the submission stays committed. The retry path then hits Submission already exists while the credit may still be unconsumed, so the atomicity guarantee this PR is aiming for is still broken. Either perform both mutations in one transaction or add compensating cleanup when credit consumption fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/api/submission/create.ts` around lines 138 - 145, The submission is
created by createSubmission(...) then consumeCredit(...) is called separately,
so if consumeCredit fails the submission remains committed; fix by making the
two operations atomic: either implement a single transactional operation (e.g.,
add a new function createSubmissionAndConsumeCredit(userId, listingId, payload,
listing) that wraps the DB insert and credit decrement in one DB transaction) or
add compensating cleanup immediately on failure (catch errors from
consumeCredit(submission.id) and call deleteSubmission(submission.id) to roll
back the inserted submission, logging and surfacing any errors). Modify the code
path around createSubmission and consumeCredit to use the transactional helper
or the compensating delete to ensure both mutations succeed or neither do.

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.

1 participant