Skip to content

feat(cdp): accept an idempotency key on the transactional email route - #120510

Merged
trunk-io[bot] merged 1 commit into
posthog/cdp-idempotency-keysfrom
posthog/cdp-transactional-email-idempotency
Oct 21, 2026
Merged

trunk-io[bot] merged 1 commit into
posthog/cdp-idempotency-keysfrom
posthog/cdp-transactional-email-idempotency

Conversation

@mayteio

@mayteio mayteio commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • A caller of the transactional email API cannot retry safely. When a request times out after cdp-api queued the email, the retry sends it a second time.
  • #117787 tried to fix this in Django and was closed. Django cannot tell whether cdp-api queued the email, so the key belongs at cdp-api's queue boundary.

Changes

  • cdp-api's transactional email route accepts an optional idempotency_key from Django. It reserves the key in the shared IdempotencyKeys store from #120446 before it queues the send.
Request Answer
First request with a key 202, the new invocation_id, replayed: false
Same key and same request within 24 hours 202, the first invocation_id, replayed: true. Nothing queues.
Same key, different request 422 idempotency_key_reused
Same key while the first request still queues 409 idempotency_key_in_flight
Key sent while the store is unavailable 503 idempotency_unavailable. Nothing queues.
  • The key is scoped to the team. A hash of the workflow, recipient, distinct id and data detects reuse for a different request.
  • The key check runs before the quota check, so a retry of a send that already went out gets its first answer after the team hits its quota.
  • A queue failure or a quota rejection frees the key, so the caller's retry can send.
  • cdp_transactional_email_requests_total gains the replayed and idempotency_* outcomes.
  • Requests without a key behave as before. Django does not send a key until #120392, so this PR changes nothing for callers by itself.

How did you test this code?

Test rationale: five cases extend the existing transactional email block in cdp-api.serial.test.ts, against the real Valkey. A retry returns the first send and queues once. A reused key returns 422. A retry during a held queue write returns 409. A queue failure frees the key for the retry. No store means 503 and nothing queued. No existing test covered a repeated request.

The end-to-end demo of the whole API, with this route and the Django half, is on #120392.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

None. The endpoint is flagged and has no public docs yet.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code (PostHog Desktop cloud task), Claude Opus 5.5


Created with PostHog Desktop

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

⚠️ Duplication (Python) — 71 new duplicated blocks (worst 339 tokens)

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
products/batch_exports/backend/tests/temporal/destinations/s3/test_activity.py:111 products/batch_exports/backend/tests/temporal/destinations/s3/test_activity.py:264 70 339
products/tasks/backend/logic/services/agent_server_launcher.py:677 products/tasks/backend/logic/services/sandbox.py:660 40 278
products/tasks/backend/logic/services/agent_server_launcher.py:302 products/tasks/backend/logic/services/docker_sandbox.py:950 38 246
products/batch_exports/backend/tests/temporal/destinations/azure_blob/utils.py:246 products/batch_exports/backend/tests/temporal/destinations/s3/utils.py:70 34 221
products/batch_exports/backend/tests/temporal/destinations/azure_blob/utils.py:246 products/batch_exports/backend/tests/temporal/destinations/base_destination_tests.py:384 34 212
products/warehouse_sources/backend/temporal/data_imports/sources/courier/courier.py:9 products/warehouse_sources/backend/temporal/data_imports/sources/solarwinds_service_desk/solarwinds_service_desk.py:6 27 204
products/warehouse_sources/backend/tests/api/test_external_data_source.py:6032 products/warehouse_sources/backend/tests/api/test_external_data_source.py:6111 49 196
products/batch_exports/backend/tests/temporal/destinations/postgres/utils.py:116 products/batch_exports/backend/tests/temporal/destinations/redshift/utils.py:142 40 195
products/batch_exports/backend/tests/temporal/destinations/bigquery/utils.py:133 products/batch_exports/backend/tests/temporal/destinations/workflows/utils.py:52 39 187
products/warehouse_sources/backend/temporal/data_imports/sources/trustpilot/trustpilot.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/veeqo/veeqo.py:31 31 187
products/batch_exports/backend/tests/temporal/destinations/s3/test_activity.py:131 products/batch_exports/backend/tests/temporal/destinations/s3/test_activity_with_gcs_bucket.py:105 33 179
products/warehouse_sources/backend/temporal/data_imports/sources/amazon_s3/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/google_cloud_storage/source.py:1 30 177
products/warehouse_sources/backend/temporal/data_imports/sources/companycam/companycam.py:6 products/warehouse_sources/backend/temporal/data_imports/sources/confluence/confluence.py:6 23 163
products/batch_exports/backend/tests/temporal/destinations/bigquery/test_workflow.py:128 products/batch_exports/backend/tests/temporal/destinations/bigquery/test_workflow.py:217 33 160
products/warehouse_sources/backend/temporal/data_imports/sources/aws_compute_optimizer/source.py:21 products/warehouse_sources/backend/temporal/data_imports/sources/aws_step_functions/source.py:18 16 141
products/warehouse_sources/backend/temporal/data_imports/sources/abnormal_security/source.py:25 products/warehouse_sources/backend/temporal/data_imports/sources/aws_config/source.py:25 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/openalex/openalex.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/sprig/sprig.py:6 16 135
products/warehouse_sources/backend/temporal/data_imports/sources/thinkific/source.py:60 products/warehouse_sources/backend/temporal/data_imports/sources/thinkific_courses/source.py:61 35 132
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 products/warehouse_sources/backend/temporal/data_imports/sources/aws_systems_manager/source.py:127 35 131
products/warehouse_sources/backend/temporal/data_imports/sources/algolia/algolia.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/qualaroo.py:7 18 127
products/warehouse_sources/backend/temporal/data_imports/sources/aws_batch/aws_batch.py:21 products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/aws_sagemaker.py:20 14 127
products/warehouse_sources/backend/temporal/data_imports/sources/lokalise/lokalise.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/ticketmaster/ticketmaster.py:1 18 126
products/warehouse_sources/backend/temporal/data_imports/sources/adobe_analytics/source.py:26 products/warehouse_sources/backend/temporal/data_imports/sources/shopify/source.py:17 15 117
products/warehouse_sources/backend/temporal/data_imports/sources/ashby/ashby.py:12 products/warehouse_sources/backend/temporal/data_imports/sources/openalex/openalex.py:7 23 117
products/warehouse_sources/backend/temporal/data_imports/sources/better_stack/better_stack.py:22 products/warehouse_sources/backend/temporal/data_imports/sources/xmatters/xmatters.py:19 16 114
products/warehouse_sources/backend/temporal/data_imports/sources/confluence/confluence.py:6 products/warehouse_sources/backend/temporal/data_imports/sources/trustpilot/trustpilot.py:7 14 111
products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/qualaroo.py:163 products/warehouse_sources/backend/temporal/data_imports/sources/rentcast/rentcast.py:143 27 111
products/warehouse_sources/backend/temporal/data_imports/sources/aws_batch/aws_batch.py:21 products/warehouse_sources/backend/temporal/data_imports/sources/aws_step_functions/aws_step_functions.py:23 12 109
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 products/warehouse_sources/backend/temporal/data_imports/sources/aws_step_functions/source.py:123 29 107
products/warehouse_sources/backend/temporal/data_imports/sources/bitrise/bitrise.py:24 products/warehouse_sources/backend/temporal/data_imports/sources/pagerduty/pagerduty.py:22 21 107
products/warehouse_sources/backend/temporal/data_imports/sources/companycam/companycam.py:6 products/warehouse_sources/backend/temporal/data_imports/sources/lattice/lattice.py:6 16 107
products/warehouse_sources/backend/temporal/data_imports/sources/infisical/infisical.py:12 products/warehouse_sources/backend/temporal/data_imports/sources/sourcegraph/sourcegraph.py:9 12 107
products/warehouse_sources/backend/temporal/data_imports/sources/azure_cost_management/source.py:82 products/warehouse_sources/backend/temporal/data_imports/sources/sharepoint/source.py:221 30 105
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 products/warehouse_sources/backend/temporal/data_imports/sources/aws_security_hub/source.py:142 28 103
products/warehouse_sources/backend/temporal/data_imports/sources/amazon_s3/source.py:11 products/warehouse_sources/backend/temporal/data_imports/sources/cloudflare_r2/source.py:12 20 102
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 products/warehouse_sources/backend/temporal/data_imports/sources/aws_savings_plans/source.py:133 27 101
products/warehouse_sources/backend/temporal/data_imports/sources/rippling/rippling.py:200 products/warehouse_sources/backend/temporal/data_imports/sources/simplesat/simplesat.py:117 22 101
products/warehouse_sources/backend/temporal/data_imports/sources/koyeb/koyeb.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/simplesat/simplesat.py:1 13 99
posthog/api/project.py:737 posthog/api/project.py:849 49 98
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/source.py:64 27 98
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 products/warehouse_sources/backend/temporal/data_imports/sources/sendowl/source.py:64 27 98
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 products/warehouse_sources/backend/temporal/data_imports/sources/shopwired/source.py:61 27 98
products/warehouse_sources/backend/temporal/data_imports/sources/amazon_s3/source.py:13 products/warehouse_sources/backend/temporal/data_imports/sources/azure_blob/source.py:12 18 97
products/warehouse_sources/backend/temporal/data_imports/sources/aha/aha.py:27 products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/qualaroo.py:17 18 94
products/access_control/backend/facade/enums.py:2 products/tracing/backend/facade/enums.py:2 14 93
products/tasks/backend/temporal/client.py:341 products/tasks/backend/temporal/client.py:445 17 92
products/warehouse_sources/backend/temporal/data_imports/sources/motion/motion.py:3 products/warehouse_sources/backend/temporal/data_imports/sources/pagerduty/pagerduty.py:11 11 89
products/warehouse_sources/backend/temporal/data_imports/sources/phyllo/phyllo.py:4 products/warehouse_sources/backend/temporal/data_imports/sources/picqer/picqer.py:7 11 89
products/tasks/backend/temporal/client.py:269 products/tasks/backend/temporal/client.py:366 15 84
products/tasks/backend/logic/services/agent_server_launcher.py:340 products/tasks/backend/logic/services/docker_sandbox.py:992 20 82
products/warehouse_sources/backend/temporal/data_imports/sources/culture_amp/source.py:68 products/warehouse_sources/backend/temporal/data_imports/sources/personio/source.py:72 23 82
products/warehouse_sources/backend/temporal/data_imports/sources/culture_amp/source.py:68 products/warehouse_sources/backend/temporal/data_imports/sources/sage_intacct/source.py:68 23 81
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:138 products/warehouse_sources/backend/temporal/data_imports/sources/justcall/source.py:67 21 77
products/warehouse_sources/backend/temporal/data_imports/sources/campaign_monitor/source.py:133 products/warehouse_sources/backend/temporal/data_imports/sources/kickscale/source.py:127 23 76
products/warehouse_sources/backend/temporal/data_imports/sources/digitalocean/settings.py:353 products/warehouse_sources/backend/temporal/data_imports/sources/digitalocean/settings.py:372 14 76
products/warehouse_sources/backend/temporal/data_imports/sources/kisi/source.py:60 products/warehouse_sources/backend/temporal/data_imports/sources/speedcurve/source.py:61 21 76
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 products/warehouse_sources/backend/temporal/data_imports/sources/trustpilot/source.py:66 22 75
products/warehouse_sources/backend/temporal/data_imports/sources/decagon/source.py:56 products/warehouse_sources/backend/temporal/data_imports/sources/lingo_dev/source.py:61 19 75
products/warehouse_sources/backend/temporal/data_imports/sources/amplemarket/source.py:64 products/warehouse_sources/backend/temporal/data_imports/sources/anvil/source.py:67 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/amplemarket/source.py:64 products/warehouse_sources/backend/temporal/data_imports/sources/apollo/source.py:71 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/amplemarket/source.py:64 products/warehouse_sources/backend/temporal/data_imports/sources/chartmogul/source.py:68 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/cloudflare/source.py:207 products/warehouse_sources/backend/temporal/data_imports/sources/coda/source.py:60 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/cloudflare/source.py:207 products/warehouse_sources/backend/temporal/data_imports/sources/monday/source.py:84 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/deel/source.py:67 products/warehouse_sources/backend/temporal/data_imports/sources/dixa/source.py:69 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/deel/source.py:67 products/warehouse_sources/backend/temporal/data_imports/sources/pingdom/source.py:67 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/hubplanner/source.py:59 products/warehouse_sources/backend/temporal/data_imports/sources/onfleet/source.py:62 19 74
products/warehouse_sources/backend/temporal/data_imports/sources/less_annoying_crm/source.py:63 products/warehouse_sources/backend/temporal/data_imports/sources/metaplane/source.py:58 19 74
products/alerts_platform/backend/activity_logging.py:29 products/logs/backend/activity_logging.py:53 15 73
products/warehouse_sources/backend/temporal/data_imports/sources/checkout_com/source.py:250 products/warehouse_sources/backend/temporal/data_imports/sources/plaid/source.py:90 18 72
products/warehouse_sources/backend/temporal/data_imports/sources/jotform/source.py:78 products/warehouse_sources/backend/temporal/data_imports/sources/knowbe4/source.py:122 20 72
products/warehouse_sources/backend/temporal/data_imports/sources/jotform/source.py:78 products/warehouse_sources/backend/temporal/data_imports/sources/komodor/source.py:111 20 72
⚠️ Duplication (TypeScript) — 11 new duplicated blocks (worst 592 tokens)

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
frontend/src/lib/utils/liquid.ts:7 nodejs/src/cdp/utils/liquid.ts:88 100 592
frontend/src/lib/oauthScopes.generated.ts:12 services/mcp/src/lib/oauth-scopes.generated.ts:12 253 521
services/mcp/scripts/generate-tools.ts:2224 services/mcp/scripts/generate-tools.ts:2249 20 194
frontend/src/scenes/dashboard/DashboardItems.test.tsx:441 frontend/src/scenes/dashboard/DashboardItems.test.tsx:493 33 161
services/mcp/scripts/generate-tools.ts:2202 services/mcp/scripts/generate-tools.ts:2230 13 156
services/mcp/scripts/yaml-config-schema.ts:190 services/mcp/scripts/yaml-config-schema.ts:611 32 113
services/mcp/scripts/yaml-config-schema.ts:201 services/mcp/src/tools/toolDefinitions.ts:23 21 94
frontend/src/lib/utils/eventUsageLogic.ts:1624 frontend/src/scenes/onboarding/shared/wizard-sync/installationProgressLogic.ts:175 28 92
frontend/src/products.tsx:2707 products/error_tracking/manifest.tsx:108 14 87
products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts:96 products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts:63 22 77
frontend/src/scenes/dashboard/dashboardLogic.tsx:4818 frontend/src/scenes/dashboard/dashboardLogic.tsx:4835 11 76
⚠️ Comment density — 4% of added code lines are comments (5 of 132)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
nodejs/src/cdp/cdp-api.ts 3 50
nodejs/src/cdp/cdp-api.serial.test.ts 1 75
nodejs/src/cdp/services/hogflows/transactional-email.ts 1 7

This check does not block merging. It updates on every push and clears when the share drops.

@posthog

posthog Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

PostHog Review · Deep · reviewed 6e40f5f

Nothing to post.

Step Status Result
Prepare the diff done 2 files, 1 chunk
Pick perspectives done 3 perspectives
Run review passes done 0 issues
Merge overlapping findings done 0 → 0
Validate findings done
Publish done Nothing to post

@trunk-io

trunk-io Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

Failed Test Failure Summary Logs
postgres parity createPerson A database operation failed because it attempted to use a PostgreSQL connection pool after the pool had been closed. Logs ↗︎

View Full Report ↗︎ ⋅ Docs

@mayteio
mayteio force-pushed the posthog/cdp-transactional-email-idempotency branch from 9c19d1f to 6e40f5f Compare October 20, 2026 17:30
@mayteio
mayteio marked this pull request as ready for review October 20, 2026 17:31
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team October 20, 2026 17:32
stamphog[bot]

This comment was marked as outdated.

@parameterai

parameterai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Risk: No findings

This PR adds optional idempotency-key handling to cdp-api's transactional-email route: the key is reserved in a team-scoped Valkey store before queueing, retries replay the first invocation, and quota-rejected or failed queue writes release the key. I found no security vulnerability: the route's JWT auth is unchanged, keys are team-scoped and hashed, the reserve is atomic, and the release/mark-queued scripts check invocation ownership so late releases cannot free another request's key. The known residual (a possible duplicate after a 60-second crash window) is a documented same-caller correctness trade-off, not a security boundary.

Sentinel reviewed 6e40f5f · Review settings

cdp-api's transactional email route accepts an optional idempotency_key from Django and reserves it in the shared IdempotencyKeys store before it checks the quota and queues the send. A repeat of the same request returns the first invocation id with replayed: true, a key reused for a different request returns 422, a repeat while the first request still queues returns 409, and an unavailable store returns 503. A queue failure or quota rejection frees the key. Django does not send the key yet, so this route behaves as before until the Django change lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 37f4fab5-e683-4a35-a865-b07749cbaa38
@mayteio
mayteio force-pushed the posthog/cdp-transactional-email-idempotency branch from 6e40f5f to 164b489 Compare October 21, 2026 09:26
@stamphog
stamphog Bot dismissed their stale review October 21, 2026 09:26

New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.

@mayteio mayteio added the stamphog Request AI approval (no full review) label Oct 21, 2026 — with PostHog

@stamphog stamphog 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.

Approved.

The risky changes have owning-team authorship and STRONG familiarity, with focused tests and no unresolved substantive review concerns. The reported database-pool test failure does not identify a defect introduced by this diff.

  • Author wrote 75% of the modified lines and has 27 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 67L, 2F substantive, 163L/3F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1c-medium (163L, 3F, single-area, feat)
stamphog 2.6.0 .stamphog/policy.yml @ 164b489 · reviewed head 164b489

@trunk-io

trunk-io Bot commented Oct 21, 2026

Copy link
Copy Markdown

This pull request is queued for merge as part of 117287, which will merge 120446, 120510, 117287.

@trunk-io
trunk-io Bot merged commit 4e75c08 into master Oct 21, 2026
254 of 256 checks passed
@trunk-io
trunk-io Bot deleted the posthog/cdp-transactional-email-idempotency branch October 21, 2026 10:19
@trunk-io

trunk-io Bot commented Oct 21, 2026

Copy link
Copy Markdown

This pull request was merged into master as part of stacked PR 117287.

@deployment-status-posthog

deployment-status-posthog Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-10-21 10:54 UTC Run
prod-us ✅ Deployed 2026-10-21 11:05 UTC Run
prod-eu ✅ Deployed 2026-10-21 11:07 UTC Run

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

Labels

stamphog Request AI approval (no full review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant