fix: SendGrid webhook batching, idempotency-key stability, TTS circuit breaker, and cross-tenant job isolation - #1246
Closed
Mystery-CLI wants to merge 4 commits into
Closed
Conversation
handle_sendgrid_webhook looped over events sequentially, awaiting each process_event one at a time. A single batch of hundreds of events could hold a DB pool connection through hundreds of sequential round trips before responding, risking SendGrid webhook timeouts and pool contention. Process events concurrently with bounded parallelism (buffer_unordered) instead.
…lock hour bucket idempotency_key rotated its hour bucket off SystemTime::now() on every call. mark_failed's exponential backoff (2/4/8/16/32/~64min...) crosses that 1-hour boundary by the 6th retry, so a retry scheduled more than an hour after the original attempt computed a different key and would not be deduplicated -- risking a duplicate send if the original attempt had actually succeeded at SendGrid before the worker crashed. The hour bucket is now derived from the job's original creation time (job.created_at) instead of the current time, so the key stays stable across a job's full retry lifetime regardless of backoff delay.
…etry attempt withRetry() wrapped _callProvider(), which itself called breaker.fire() on every retry attempt. A single failed user request could generate up to maxRetries+1 breaker failures instead of one, tripping the breaker's volumeThreshold/errorThresholdPercentage far earlier than intended. Once open, the breaker's OpenCircuitError was rewrapped as a 503 TTSProviderError, which isRetryable() treats as retryable, so the outer retry loop kept retrying an open breaker with full exponential backoff instead of failing fast -- the opposite of the breaker's purpose. Move the retry/backoff loop inside each breaker's wrapped action so opossum's fire() is called exactly once per external request. When the circuit is open, fire() now rejects immediately without invoking the action at all, so callers fail fast with zero retries.
jobStore was a single global Map with no owner field. GET /tts/jobs and GET /tts/job/:id returned every job in the process -- including other tenants' narration text, voice, and file paths -- to any caller that passed the (optional) auth check, since neither endpoint filtered by the requesting credential. Tag each job with the owner identity resolved from its creating credential (the API key itself, or a JWT's `sub` claim; "anonymous" when no auth is configured), and require getJob/listJobs to match it. getJob returns the same 404 for "not found" and "belongs to another tenant" so a credential can't distinguish the two by probing IDs. Internal operational code (health checks, queue-depth metrics) that needs a cross-tenant view now uses the new listAllJobsUnscoped(). Also fixes /tts/enqueue and /tts/generate, which were never passing the authenticated request's credential down to the service layer at all.
|
@Mystery-CLI Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes four issues surfaced by the automated multi-agent codebase audit, spanning the email service (Rust/
services/api) and the TTS service (TypeScript/services/tts). Each fix is isolated to its own commit.1. Sequential SendGrid webhook batch processing (#1128)
handle_sendgrid_webhooklooped over events one at a time, awaitingprocess_eventsequentially. Eachprocess_eventperforms up to 4 sequential DB/Redis round trips, so a batch of ~300 events could hold a DB pool connection through 600–1200 sequential round trips before responding — risking SendGrid webhook timeouts and pool contention.Fix: Process events concurrently with bounded parallelism (
futures::stream::buffer_unordered, capped at 16 in-flight) instead of a plain sequential loop. DB/Redis round trips overlap instead of stacking serially, so wall-clock time no longer scales linearly with batch size.services/api/src/email/webhook.rs2. Idempotency key rotation can outlive retry backoff (#1129)
idempotency_keyderived its hourly rotation bucket fromSystemTime::now()on every call.mark_failed's exponential backoff (2/4/8/16/32/~64min...) crosses that 1-hour boundary by the 6th retry. If a send actually succeeded at SendGrid but the worker crashed beforemark_completed, a retry scheduled more than an hour later would compute a different key and skip deduplication — resulting in a duplicate email to the recipient.Fix: The hour bucket is now derived from the job's original
created_attimestamp instead of wall-clock "now", so the key stays stable across a job's entire retry lifetime regardless of how long the backoff delay runs. Added a test simulating a retry 90 minutes after creation (crossing the hour boundary) asserting the key is unchanged.services/api/src/email/service.rsservices/api/src/email/queue.rs3. Retry logic wraps the circuit breaker (#1131)
withRetry()called_callProvider()— which itself calledbreaker.fire()— on every retry attempt. A single failed user request could generate up tomaxRetries + 1breaker failures instead of one, tripping the volume/error thresholds far earlier than intended. Worse, once the breaker was OPEN,_callProviderre-wrapped the open-circuit error as a 503TTSProviderError, whichisRetryable()treats as retryable — sowithRetrykept retrying an open breaker with full exponential backoff, the opposite of the breaker's purpose.Fix: Moved the retry/backoff loop inside each circuit breaker's wrapped action, so
breaker.fire()is invoked exactly once per external request. When the circuit is open,fire()now rejects immediately without ever invoking the action (and therefore without any retry/backoff), giving true fast-fail behavior.services/tts/src/TTSService.ts4. TTS job endpoints leak data across tenants (#1130)
jobStorewas a single globalMapwith no owner/tenant field.GET /tts/jobsandGET /tts/job/:idreturned every job in the process — including other tenants' narration text, voice, provider, and output paths — to any caller that passed the (optional) auth check, since neither endpoint filtered by the requesting credential. Additionally,/tts/enqueueand/tts/generatenever passed the authenticated request's credential down to the service layer at all.Fix: Each job is now tagged with an
owneridentity resolved from its creating credential (the API key itself, or a JWT'ssubclaim;"anonymous"when no auth is configured).getJob/listJobsrequire the owner to match, andgetJobreturns the same 404 for "not found" and "belongs to another tenant" so a credential can't distinguish the two by probing IDs. Internal operational code (health checks, queue-depth metrics) that needs a cross-tenant view now uses a newlistAllJobsUnscoped(). Wired the missing credential through/tts/enqueueand/tts/generate. Added tests creating jobs under two different API keys and asserting cross-tenant access is denied.services/tts/src/TTSService.tsservices/tts/src/server.tsservices/tts/src/HealthCheck.tsservices/tts/src/__tests__/auth.test.tsTest plan
cd services/api && cargo clippy --all-targets --all-features -- -D warningscd services/api && cargo test webhookcd services/api && cargo test servicecd services/api && cargo build --releasecd services/tts && npx tsc --noEmit --project tsconfig.jsoncd services/tts && npm test -- TTSServicecd services/tts && npm test -- servercd services/tts && npm run buildNote: this environment had no Rust toolchain available, so the Rust-side changes (#1128, #1129) were not compiled or test-run locally — please verify in CI before merging.
Closes #1128
Closes #1129
Closes #1130
Closes #1131
🤖 Generated with Claude Code