Skip to content

feat: add email send idempotency caching#281

Closed
KMKoushik wants to merge 1 commit intomainfrom
codex/implement-idempotency-key-for-email-sending
Closed

feat: add email send idempotency caching#281
KMKoushik wants to merge 1 commit intomainfrom
codex/implement-idempotency-key-for-email-sending

Conversation

@KMKoushik
Copy link
Copy Markdown
Member

@KMKoushik KMKoushik commented Oct 24, 2025

Summary

  • add support for optional idempotency keys on the email send API, including Redis-backed reservation, cached responses, and 3-day expiry logging
  • extend Redis helpers plus TypeScript and Python SDKs to normalize the idempotency key before sending requests
  • document the new behaviour with a dedicated guide and navigation entry

Testing

  • pnpm lint --filter web (fails: existing warnings in @usesend/ui lint task)

https://chatgpt.com/codex/tasks/task_e_68faf7f22c5c8329a7f92d920f50693a


Summary by cubic

Adds optional idempotencyKey to the email send API to deduplicate duplicate requests, using Redis reservations and cached results with a 3-day TTL. SDKs normalize the key, and a new guide documents usage.

  • New Features
    • Email send accepts idempotencyKey; duplicate keys return the first email result (scoped per team).
    • Redis-backed reservation with a 3-day TTL; states tracked during PENDING/CREATED/QUEUED/FAILED.
    • In-progress duplicate requests respond with a “request already in progress” error.
    • Failed sends are retriable; previous FAILED keys are cleared on the next attempt.
    • JS and Python SDKs trim and drop empty idempotencyKey values; OpenAPI/schema updated.
    • Added “Email idempotency” guide and docs navigation entry.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added email idempotency support: use an idempotencyKey parameter when sending emails to deduplicate requests and reuse cached responses for up to 3 days.
  • Documentation

    • New guide on email idempotency usage and best practices added to the documentation library.

@vercel
Copy link
Copy Markdown

vercel bot commented Oct 24, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
unsend-marketing Ready Ready Preview Comment Oct 24, 2025 7:23pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Oct 24, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This PR adds email idempotency functionality across the codebase. An optional idempotencyKey field is introduced to email send requests that deduplicates requests within a team for 3 days using Redis-backed records. The implementation includes updates to the email service to track idempotency states (PENDING, CREATED, QUEUED, FAILED), new Redis utilities for JSON serialization, schema updates in multiple SDKs (TypeScript and Python), API endpoint modifications, and refactoring of request body validation in the send-email handler. Documentation is added explaining idempotency behavior with code examples.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The pull request title "feat: add email send idempotency caching" directly and accurately describes the primary change in the changeset. The PR introduces email send idempotency with Redis-backed caching, extends Redis utilities, updates schemas and SDKs to support an optional idempotencyKey field, and adds comprehensive documentation for this feature. The title is specific, concise, and clearly communicates the main objective without vague terminology or misleading information. A developer scanning the commit history would immediately understand that this PR adds idempotency caching capabilities to the email sending functionality.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/implement-idempotency-key-for-email-sending

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
Copy Markdown

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

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.

ℹ️ 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".

Comment on lines +189 to +193
const pendingRecord: EmailIdempotencyRecord = {
status: "PENDING",
createdAt: existingRecord?.createdAt ?? nowIso,
lastUpdatedAt: nowIso,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear idempotency key when validation fails before queuing

The code writes a PENDING idempotency record in Redis as soon as an idempotencyKey is supplied, but there is no matching cleanup when the function exits early before the queueing try block (e.g., invalid API key, suppressed recipients, or any other validation error thrown later in sendEmail). In those cases the Redis key remains stuck with status PENDING for the full three‑day TTL, so subsequent requests with the same idempotency key will always hit the NOT_UNIQUE branch even though no email was actually processed. This blocks legitimate retries and leaks keys until the TTL expires. Consider wrapping the validation and early returns in a try/finally that deletes or marks the key as FAILED so clients can retry safely.

Useful? React with 👍 / 👎.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying usesend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 65a0904
Status: ✅  Deploy successful!
Preview URL: https://d9e2f633.usesend.pages.dev
Branch Preview URL: https://codex-implement-idempotency.usesend.pages.dev

View logs

Copy link
Copy Markdown
Contributor

@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: 2

🧹 Nitpick comments (1)
apps/web/src/server/redis.ts (1)

82-94: Consider logging JSON parse errors for observability.

The getJsonValue function silently returns null when JSON parsing fails (lines 89-93). While acceptable for cache misses, this could hide data corruption issues in idempotency records. Consider logging parse errors to aid debugging:

   try {
     return JSON.parse(cached) as T;
   } catch {
+    logger.warn({ key }, "Failed to parse JSON from Redis");
     return null;
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and 65a0904.

📒 Files selected for processing (11)
  • apps/docs/docs.json (1 hunks)
  • apps/docs/guides/email-idempotency.mdx (1 hunks)
  • apps/web/src/server/public-api/api/emails/send-email.ts (1 hunks)
  • apps/web/src/server/public-api/schemas/email-schema.ts (1 hunks)
  • apps/web/src/server/redis.ts (1 hunks)
  • apps/web/src/server/service/email-service.ts (5 hunks)
  • apps/web/src/types/index.ts (1 hunks)
  • packages/python-sdk/usesend/emails.py (1 hunks)
  • packages/python-sdk/usesend/types.py (1 hunks)
  • packages/sdk/src/email.ts (1 hunks)
  • packages/sdk/types/schema.d.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports, and ensure proper naming of key components.

Files:

  • apps/web/src/server/redis.ts
  • packages/sdk/types/schema.d.ts
  • apps/web/src/server/public-api/schemas/email-schema.ts
  • packages/sdk/src/email.ts
  • apps/web/src/types/index.ts
  • apps/web/src/server/service/email-service.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: TypeScript-first: use .ts/.tsx for source code (avoid JavaScript source files)
Use 2-space indentation and semicolons (Prettier 3 enforces these)
Adhere to @usesend/eslint-config; fix all ESLint warnings (CI fails on warnings)
Do not use dynamic imports; always place imports at the top of the module

Files:

  • apps/web/src/server/redis.ts
  • packages/sdk/types/schema.d.ts
  • apps/web/src/server/public-api/schemas/email-schema.ts
  • packages/sdk/src/email.ts
  • apps/web/src/types/index.ts
  • apps/web/src/server/service/email-service.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
**/*.{ts,tsx,md}

📄 CodeRabbit inference engine (AGENTS.md)

Format code with Prettier 3 (run pnpm format)

Files:

  • apps/web/src/server/redis.ts
  • packages/sdk/types/schema.d.ts
  • apps/web/src/server/public-api/schemas/email-schema.ts
  • packages/sdk/src/email.ts
  • apps/web/src/types/index.ts
  • apps/web/src/server/service/email-service.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

apps/web/**/*.{ts,tsx}: In apps/web, use the / alias for src imports (e.g., import { x } from "/utils/x")
Prefer using tRPC in apps/web unless explicitly asked otherwise

Files:

  • apps/web/src/server/redis.ts
  • apps/web/src/server/public-api/schemas/email-schema.ts
  • apps/web/src/types/index.ts
  • apps/web/src/server/service/email-service.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
🧬 Code graph analysis (3)
apps/web/src/server/service/email-service.ts (4)
apps/web/src/server/redis.ts (3)
  • getJsonValue (82-94)
  • deleteKey (96-99)
  • setJsonValue (57-80)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/logger/log.ts (1)
  • logger (31-63)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/public-api/api/emails/send-email.ts (1)
apps/web/src/server/service/email-service.ts (1)
  • sendEmail (114-498)
packages/python-sdk/usesend/emails.py (2)
packages/python-sdk/usesend/usesend.py (1)
  • get (110-111)
packages/python-sdk/usesend/contacts.py (1)
  • get (34-40)
🔇 Additional comments (15)
packages/python-sdk/usesend/types.py (1)

213-213: LGTM! Idempotency key field added correctly.

The optional idempotencyKey field is properly typed and integrated into the EmailCreate TypedDict.

packages/python-sdk/usesend/emails.py (1)

45-50: LGTM! Idempotency key normalization is correctly implemented.

The normalization logic properly trims whitespace and removes empty keys before sending the request. This ensures clean, consistent idempotency keys reach the API.

apps/web/src/server/public-api/schemas/email-schema.ts (1)

10-19: LGTM! Schema validation is properly configured.

The idempotency key validation includes trimming, length constraints (1-255 chars), and clear API documentation. This provides robust server-side validation.

apps/docs/docs.json (1)

41-44: LGTM! Documentation navigation updated correctly.

The new idempotency guide is properly integrated into the Guides section.

apps/docs/guides/email-idempotency.mdx (1)

1-49: LGTM! Clear and comprehensive documentation.

The idempotency guide effectively explains the feature with practical examples for both JavaScript and Python SDKs. The note about cached responses and automatic failure cleanup provides important usage context.

packages/sdk/src/email.ts (1)

85-93: LGTM! Idempotency key normalization is correctly implemented.

The normalization logic properly trims whitespace and removes empty keys, consistent with the Python SDK implementation. This ensures clean, consistent idempotency keys reach the API.

apps/web/src/types/index.ts (1)

4-4: LGTM! Type definition added correctly.

The optional idempotencyKey field is properly integrated into the EmailContent type.

packages/sdk/types/schema.d.ts (1)

573-574: LGTM! Schema definition correctly includes idempotency key.

The auto-generated schema properly reflects the idempotencyKey field with appropriate documentation. The source OpenAPI specification is correctly configured.

apps/web/src/server/public-api/api/emails/send-email.ts (1)

35-50: LGTM! Clean refactor improves readability.

Consolidating the validated JSON payload into a single body variable eliminates redundant c.req.valid("json") calls and makes the code more maintainable. The optional chaining on line 39 (body?.html) is appropriate defensive coding for an optional schema field.

apps/web/src/server/redis.ts (1)

96-99: LGTM!

Simple, correct implementation of the Redis DEL wrapper.

apps/web/src/server/service/email-service.ts (5)

16-18: LGTM! Well-defined idempotency constants.

The 3-day TTL aligns with the PR objectives and provides a reasonable window for duplicate detection. The 500ms total wait time (5 × 100ms) appropriately handles concurrent request resolution without excessive blocking.


31-43: LGTM! Correct concurrent request handling.

The polling logic correctly exits early when the idempotent operation completes (emailId present), fails, or the record is deleted. The 500ms total wait time balances responsiveness with handling legitimate concurrent requests.


45-72: LGTM! Robust cached result resolution.

Good defensive programming: handles stale Redis records (emailId present but email deleted from DB) by cleaning up the key. The informative logging aids debugging and makes the 3-day expiry explicit.


421-462: LGTM! Correct idempotency record lifecycle updates.

The CREATED and QUEUED state transitions correctly preserve the original createdAt timestamp while updating lastUpdatedAt and adding status-specific metadata (emailId, queuedAt). These updates will work correctly once the critical error-handling issue (lines 139-224) is addressed.


479-493: LGTM! Correct failure state recording for queueing errors.

The FAILED state update correctly captures queueing errors with the error message and timestamp. This handles the post-creation failure path properly. The pre-creation failure path (validation errors, etc.) requires the try-catch wrapper suggested in the earlier comment for lines 139-224.

Comment on lines +57 to +80
export async function setJsonValue(
key: string,
value: unknown,
options?: RedisSetJsonOptions
): Promise<"OK" | null> {
const redis = getRedis();
const payload = JSON.stringify(value);
const mode = options?.mode;
const ttlSeconds = options?.ttlSeconds;

if (mode && ttlSeconds) {
return redis.set(key, payload, mode, "EX", ttlSeconds);
}

if (mode) {
return redis.set(key, payload, mode);
}

if (ttlSeconds) {
return redis.set(key, payload, "EX", ttlSeconds);
}

return redis.set(key, payload);
}
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 | 🔴 Critical

🧩 Analysis chain

Verify ioredis SET command argument order.

The setJsonValue implementation on line 68 passes arguments as redis.set(key, payload, mode, "EX", ttlSeconds) where mode is "NX" or "XX". While Redis accepts these flags in any order, ensure that ioredis v5.6.1 TypeScript overloads support this exact signature to avoid type errors or runtime issues.

Alternatively, test this syntax in the integration tests or verify against ioredis documentation:


🏁 Script executed:

#!/bin/bash
# Verify ioredis SET command syntax supports NX/XX with EX in this argument order

# Search for ioredis SET usage patterns in the codebase
rg -n -C3 'redis\.set\(' --type=ts

# Check ioredis type definitions
fd -e d.ts -x cat {} \; | rg -A5 'interface Redis' | head -50

Length of output: 960


🌐 Web query:

ioredis v5 SET command with NX and EX options syntax

💡 Result:

Use positional arguments (they're passed straight to Redis). Example:

  • redis.set("key", "value", "EX", 60, "NX"); // set if not exists, expires in 60s. [1][2]

Notes:

  • EX takes seconds (or use "PX" and milliseconds). NX and XX are mutually exclusive. [2]

Sources:
[1] ioredis README examples.
[2] Redis SET command documentation.


Fix argument order for redis.set() with NX/XX and EX options on line 68.

The call on line 68 passes arguments in incorrect order: redis.set(key, payload, mode, "EX", ttlSeconds). According to Redis protocol, expiration options (EX, seconds) must come before NX/XX flags. Correct the order to: redis.set(key, payload, "EX", ttlSeconds, mode).

🤖 Prompt for AI Agents
In apps/web/src/server/redis.ts around lines 57 to 80, the redis.set call that
combines mode (NX/XX) and expiration currently passes arguments in the wrong
order; change the call from redis.set(key, payload, mode, "EX", ttlSeconds) to
pass the expiration before the mode, i.e. redis.set(key, payload, "EX",
ttlSeconds, mode), ensuring all other branches remain consistent and return the
redis.set result.

Comment on lines +139 to +224
const normalizedIdempotencyKey = idempotencyKey?.trim();
const idempotencyRedisKey = normalizedIdempotencyKey
? `email-idempotency:${teamId}:${normalizedIdempotencyKey}`
: null;
let idempotencyRecordSnapshot: EmailIdempotencyRecord | null = null;
let idempotencyCreatedAt = new Date().toISOString();

if (idempotencyRedisKey) {
let existingRecord = await getJsonValue<EmailIdempotencyRecord>(
idempotencyRedisKey
);

if (existingRecord?.status === "FAILED") {
await deleteKey(idempotencyRedisKey);
existingRecord = null;
} else {
const cachedEmail = await resolveEmailFromRecord(
existingRecord,
idempotencyRedisKey,
teamId
);

if (cachedEmail) {
return cachedEmail;
}

if (existingRecord?.status === "PENDING") {
const awaitedRecord = await waitForIdempotentRecord(
idempotencyRedisKey
);
const awaitedEmail = await resolveEmailFromRecord(
awaitedRecord,
idempotencyRedisKey,
teamId
);

if (awaitedEmail) {
return awaitedEmail;
}

if (awaitedRecord?.status === "FAILED") {
await deleteKey(idempotencyRedisKey);
existingRecord = null;
} else {
existingRecord = awaitedRecord;
}
}
}

const nowIso = new Date().toISOString();
const pendingRecord: EmailIdempotencyRecord = {
status: "PENDING",
createdAt: existingRecord?.createdAt ?? nowIso,
lastUpdatedAt: nowIso,
};

const setResult = await setJsonValue(idempotencyRedisKey, pendingRecord, {
ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
mode: "NX",
});

if (setResult !== "OK") {
const latestRecord = await waitForIdempotentRecord(idempotencyRedisKey);
const latestEmail = await resolveEmailFromRecord(
latestRecord,
idempotencyRedisKey,
teamId
);

if (latestEmail) {
return latestEmail;
}

if (latestRecord?.status === "FAILED") {
await deleteKey(idempotencyRedisKey);
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "A request with this idempotency key is already in progress.",
});
}

idempotencyCreatedAt = pendingRecord.createdAt;
idempotencyRecordSnapshot = pendingRecord;
}
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 | 🔴 Critical

Critical: Wrap idempotency flow in try-catch to clean up PENDING records.

If an error occurs after creating the PENDING record (line 195) but before updating to CREATED (line 430)—such as validation failures on lines 236-246 or lines 377-389—the PENDING record remains stuck in Redis for 3 days. Subsequent requests with the same idempotencyKey will wait, then throw NOT_UNIQUE errors until the TTL expires.

Wrap the entire flow from line 224 onward in a try-catch block to update the record to FAILED on any error:

     idempotencyCreatedAt = pendingRecord.createdAt;
     idempotencyRecordSnapshot = pendingRecord;
   }
+
+  try {

   let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
   
   // ... (rest of the email creation flow)
   
   return email;
+
+  } catch (error: any) {
+    if (idempotencyRedisKey) {
+      const failedAt = new Date().toISOString();
+      const failureRecord: EmailIdempotencyRecord = {
+        status: "FAILED",
+        createdAt: idempotencyCreatedAt,
+        lastUpdatedAt: failedAt,
+        error: error instanceof Error ? error.message : String(error),
+      };
+      await setJsonValue(idempotencyRedisKey, failureRecord, {
+        ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
+      });
+    }
+    throw error;
+  }
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/src/server/service/email-service.ts around lines 139 to 224, the
idempotency PENDING record can be left in Redis if an error occurs after it is
created; wrap the entire processing flow that follows creation of the PENDING
record (starting just after the setJsonValue that writes PENDING) in a try-catch
so that any thrown error results in updating the idempotency Redis key to a
FAILED EmailIdempotencyRecord (set status: "FAILED" and lastUpdatedAt to nowIso,
keep createdAt from the snapshot) before rethrowing; ensure the update is
written atomically (overwrite) and handle cases where the key was removed by
other flows (no-op if delete occurs), and rethrow the original error so upstream
error handling remains unchanged.

@KMKoushik KMKoushik closed this Oct 24, 2025
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

3 issues found across 11 files

Prompt for AI agents (all 3 issues)

Understand the root cause of the following 3 issues and fix them.


<file name="apps/docs/guides/email-idempotency.mdx">

<violation number="1" location="apps/docs/guides/email-idempotency.mdx:9">
This line claims all duplicate requests return the first queued email, but the API sends an &quot;already in progress&quot; error when the original request hasn&#39;t finished. Please clarify the pending-request behavior.</violation>
</file>

<file name="apps/web/src/server/service/email-service.ts">

<violation number="1" location="apps/web/src/server/service/email-service.ts:195">
Errors thrown after creating the PENDING idempotency record can leave the key stuck for up to 3 days, causing subsequent requests with the same idempotencyKey to fail with NOT_UNIQUE. Wrap the processing that follows creation of the PENDING record in a try-catch and, on any error, update the Redis record to status &quot;FAILED&quot; (preserving createdAt and setting lastUpdatedAt) before rethrowing to avoid blocking retries.</violation>

<violation number="2" location="apps/web/src/server/service/email-service.ts:457">
If the Redis cache write fails after queueEmail succeeds, this line throws and we immediately mark the send as FAILED even though the job is already queued, so callers see an error and may retry, generating duplicate sends. Guard the idempotency cache update so Redis failures don&#39;t flip the request into the failure path.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

## Overview

When you provide an `idempotencyKey` with the email send endpoint, useSend will
short-circuit duplicate requests and return the first queued email. The key is
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 24, 2025

Choose a reason for hiding this comment

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

This line claims all duplicate requests return the first queued email, but the API sends an "already in progress" error when the original request hasn't finished. Please clarify the pending-request behavior.

Prompt for AI agents
Address the following comment on apps/docs/guides/email-idempotency.mdx at line 9:

<comment>This line claims all duplicate requests return the first queued email, but the API sends an &quot;already in progress&quot; error when the original request hasn&#39;t finished. Please clarify the pending-request behavior.</comment>

<file context>
@@ -0,0 +1,49 @@
+## Overview
+
+When you provide an `idempotencyKey` with the email send endpoint, useSend will
+short-circuit duplicate requests and return the first queued email. The key is
+scoped to the team and automatically expires after three days so you do not need
+any manual cleanup.
</file context>
Fix with Cubic

lastUpdatedAt: queuedAt,
};

await setJsonValue(idempotencyRedisKey, queuedRecord, {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 24, 2025

Choose a reason for hiding this comment

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

If the Redis cache write fails after queueEmail succeeds, this line throws and we immediately mark the send as FAILED even though the job is already queued, so callers see an error and may retry, generating duplicate sends. Guard the idempotency cache update so Redis failures don't flip the request into the failure path.

Prompt for AI agents
Address the following comment on apps/web/src/server/service/email-service.ts at line 457:

<comment>If the Redis cache write fails after queueEmail succeeds, this line throws and we immediately mark the send as FAILED even though the job is already queued, so callers see an error and may retry, generating duplicate sends. Guard the idempotency cache update so Redis failures don&#39;t flip the request into the failure path.</comment>

<file context>
@@ -280,6 +443,23 @@ export async function sendEmail(
+        lastUpdatedAt: queuedAt,
+      };
+
+      await setJsonValue(idempotencyRedisKey, queuedRecord, {
+        ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
+      });
</file context>
Fix with Cubic

lastUpdatedAt: nowIso,
};

const setResult = await setJsonValue(idempotencyRedisKey, pendingRecord, {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 24, 2025

Choose a reason for hiding this comment

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

Errors thrown after creating the PENDING idempotency record can leave the key stuck for up to 3 days, causing subsequent requests with the same idempotencyKey to fail with NOT_UNIQUE. Wrap the processing that follows creation of the PENDING record in a try-catch and, on any error, update the Redis record to status "FAILED" (preserving createdAt and setting lastUpdatedAt) before rethrowing to avoid blocking retries.

Prompt for AI agents
Address the following comment on apps/web/src/server/service/email-service.ts at line 195:

<comment>Errors thrown after creating the PENDING idempotency record can leave the key stuck for up to 3 days, causing subsequent requests with the same idempotencyKey to fail with NOT_UNIQUE. Wrap the processing that follows creation of the PENDING record in a try-catch and, on any error, update the Redis record to status &quot;FAILED&quot; (preserving createdAt and setting lastUpdatedAt) before rethrowing to avoid blocking retries.</comment>

<file context>
@@ -72,10 +131,98 @@ export async function sendEmail(
+      lastUpdatedAt: nowIso,
+    };
+
+    const setResult = await setJsonValue(idempotencyRedisKey, pendingRecord, {
+      ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
+      mode: &quot;NX&quot;,
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant