Repository navigation
Propagate the billing request deadline into BillingService so a 504 never races a live Soroban deduction #1507
Description
Summary
Every /api/billing/* request runs under createTimeoutMiddleware({ timeoutMs: config.billingTimeoutMs }), which defaults to 5 seconds. BillingService.deduct knows nothing about that deadline. It can wait in the per-user semaphore queue, then call Soroban up to four times with a 5-second RPC timeout each, plus backoff. When the HTTP deadline fires, the client gets a 504 and the idempotency row is deleted (status ≥ 500), but the deduction keeps running and may succeed on-chain. The client's correct retry then finds a pending row and gets a 409. This issue threads the request's AbortSignal through the billing path and aligns the retry budget with the deadline.
Background and evidence
- 5s deadline on all billing routes.
src/routes/billing.ts(line 41) appliesrouter.use(createTimeoutMiddleware({ timeoutMs: config.billingTimeoutMs })).config.billingTimeoutMsdefaults to 5000 (src/config/index.ts, line 258). - The signal is exposed but unused.
src/middleware/timeout.tsattachesreq.abortSignal/req.signaland aborts it on deadline, then sends a 504 if headers are not sent. Nothing on the billing path reads the signal. - Retry budget exceeds the deadline.
src/services/billing.ts,executeDeductWithRetry(about lines 949-989), runs up toretryDelaysMs.length + 1 = 4attempts with delays up to 150/500/1000 ms.SorobanRpcBillingClientusesSOROBAN_BILLING_RPC_TIMEOUT_MS(default 5000) per attempt (src/services/sorobanBilling.ts, line 51 and theAbortControllerininvoke). The worst case is about 21.6s against a 5s HTTP deadline. - Unbounded queue wait.
billingConcurrencySemaphore.withSlot(line 488) has no timeout, so a queued request can start its deduction after its own HTTP response has already been sent. - Idempotency record removed.
src/middleware/idempotency.tsdeletes the record for status ≥ 500, so the deduction that finishes after the timeout is not linked to the client's key.
Scope
In scope
- Signal plumbing. Add an optional
signal?: AbortSignaltoBillingDeductRequestanddeductBulk. The deduct and bulk routes passreq.abortSignal. - Before Phase 1. If the signal is aborted while waiting for the semaphore or the balance pre-check, abort without inserting a row (no pending row and no charge). Make
DeveloperSemaphore.withSlotaccept a signal and remove the waiter from the queue on abort. - After Phase 1. Once the Soroban submission has started, do not abandon it. Stop scheduling new retries after the deadline, record the row as pending, and return
reconciliationRequired. Never send a second transaction after the deadline. - Budget alignment. Derive the per-attempt RPC timeout and the number of retries from the remaining time on the signal. Log a startup warning if
SOROBAN_BILLING_RPC_TIMEOUT_MSis greater than or equal toBILLING_TIMEOUT_MS. - Timeout response. When the route times out after Phase 1, respond with the pending-reconciliation envelope (409 or 202, documented) instead of a bare 504, and keep the idempotency record so the retry is consistent.
- Docs. Update
docs/api-plans-timeout.mdanddocs/sdk/billing-deduct.md.
Out of scope
- The stellar-sdk client rewrite.
- Resolving pending rows (a separate issue).
Design constraints
- Never charged after a pre-insert abort. An abort before Phase 1 must leave no row and make no RPC call.
- One transaction per deadline. At most one on-chain transaction is submitted per deduction attempt, and none after the signal aborts.
- Clean queue. Semaphore waiters removed on abort must not leak slots.
Test plan
Use fake timers and a stub Soroban client. Extend src/services/billing.semaphore.test.ts and src/routes/billing/deduct.test.ts.
| # | Scenario | Expected |
|---|---|---|
| 1 | Deadline fires while queued in the semaphore | no row inserted; no RPC call; waiter removed |
| 2 | Deadline fires during the balance check | no row; typed timeout |
| 3 | Deadline fires during the first Soroban attempt | no retry scheduled after abort; row pending; response is the reconciliation envelope |
| 4 | Remaining budget 1.2s | per-attempt timeout ≤ 1.2s; at most the attempts that fit |
| 5 | Normal fast path | unchanged behaviour |
| 6 | Retry with the same Idempotency-Key after case 3 | consistent replay, not a fresh 409 from a deleted key |
| 7 | Config with RPC timeout ≥ billing timeout | startup warning logged |
Regression proof. In row 1 on current main, the deduction starts after the 504 has been sent.
Acceptance criteria
- Every test-plan row passes.
-
npm run lint,npm run typecheck,npm run buildandnpm testpass, and CI is green. - At least 90% line coverage on the touched billing and semaphore code.
- OpenAPI documents the timeout response for
/api/billing/deduct, andnpm run validate:openapipasses.
Getting started
git checkout -b fix/billing-deadline-propagation
npm ci && npx prisma generate
npm run test:unit -- src/services/billing src/routes/billing/deduct src/middleware/timeoutRead first: src/middleware/timeout.ts, deduct / deductInternal / executeDeductWithRetry in src/services/billing.ts, src/utils/developerSemaphore.ts, and src/routes/billing/deduct.ts.
PR requirements
- Link this issue (
Closes #<n>). - Include a timeline diagram of the deadline versus attempts, before and after.
- Paste test output and coverage.
- Example commit:
fix(billing): honour request deadline across semaphore and Soroban retries.
Priority rationale
Medium. The change is contained to the billing call path, but today timeouts produce charges the client cannot see and retries that are refused. Fixing it requires careful cancellation semantics around an external ledger.
Activity
anniechiamaka1-hub commentedon Oct 18, 2026
@anniechiamaka1-hub has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @anniechiamaka1-hub to this issue.
drips-wave commentedon Oct 19, 2026
Congratulations, @anniechiamaka1-hub! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @anniechiamaka1-hub: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊