Skip to content

fix: SendGrid webhook batching, idempotency-key stability, TTS circuit breaker, and cross-tenant job isolation - #1249

Merged
hman38705 merged 5 commits into
mainfrom
fix/audit-findings-batch-1
Jul 28, 2026
Merged

fix: SendGrid webhook batching, idempotency-key stability, TTS circuit breaker, and cross-tenant job isolation#1249
hman38705 merged 5 commits into
mainfrom
fix/audit-findings-batch-1

Conversation

@hman38705

Copy link
Copy Markdown
Contributor

Conflict-resolved re-submission of #1246 (fork PR, no push access to update it directly). Merges main in and reconciles the circuit-breaker retry/timeout logic with #1246's original changes. Closes #1246 in favor of this.

Mystery-CLI and others added 5 commits July 28, 2026 06:20
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.
@hman38705
hman38705 merged commit cfc2f9f into main Jul 28, 2026
3 of 16 checks passed
hman38705 pushed a commit that referenced this pull request Jul 28, 2026
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.

2 participants