Skip to content

Propagate the billing request deadline into BillingService so a 504 never races a live Soroban deduction #1507

Open

Description

@greatest0fallt1me

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) applies router.use(createTimeoutMiddleware({ timeoutMs: config.billingTimeoutMs })). config.billingTimeoutMs defaults to 5000 (src/config/index.ts, line 258).
  • The signal is exposed but unused. src/middleware/timeout.ts attaches req.abortSignal / req.signal and 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 to retryDelaysMs.length + 1 = 4 attempts with delays up to 150/500/1000 ms. SorobanRpcBillingClient uses SOROBAN_BILLING_RPC_TIMEOUT_MS (default 5000) per attempt (src/services/sorobanBilling.ts, line 51 and the AbortController in invoke). 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.ts deletes the record for status ≥ 500, so the deduction that finishes after the timeout is not linked to the client's key.

Scope

In scope

  1. Signal plumbing. Add an optional signal?: AbortSignal to BillingDeductRequest and deductBulk. The deduct and bulk routes pass req.abortSignal.
  2. 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.withSlot accept a signal and remove the waiter from the queue on abort.
  3. 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.
  4. 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_MS is greater than or equal to BILLING_TIMEOUT_MS.
  5. 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.
  6. Docs. Update docs/api-plans-timeout.md and docs/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 build and npm test pass, 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, and npm run validate:openapi passes.

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/timeout

Read 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

  1. anniechiamaka1-hub commented on Oct 18, 2026

    @anniechiamaka1-hub

    @anniechiamaka1-hub has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    I would like to work on this issue

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @anniechiamaka1-hub to this issue.

  2. drips-wave commented on Oct 19, 2026

    @drips-wave

    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 🌊

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

Metadata

Metadata

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions