Skip to content

feat(realtime): honour capacity retry_after, add capacityRetryBudgetMs and onCapacityWait - #221

Merged
AdirAmsalem merged 1 commit into
mainfrom
conductor/sdk-capacity-retry-after
Oct 11, 2026
Merged

AdirAmsalem merged 1 commit into
mainfrom
conductor/sdk-capacity-retry-after

Conversation

@AdirAmsalem

@AdirAmsalem AdirAmsalem commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When the realtime pool is full, the server refuses the dial with close 1013 "Try Again Later" and the SDK re-dialled on p-retry's exponential backoff (1 s, 2 s, 4 s, 8 s, 10 s, then give up). It neither waited as long as the server asked nor kept trying for as long as a slot was likely to free up, and the app had no signal that a slow connect() was a capacity wait.

The server now tells capacity-refused clients when to come back. The refusal is one error message, then the unchanged close:

{"type": "error", "error": "Server at capacity. Please try again later.", "error_type": "capacity", "retry_after": 5}

retry_after is in seconds and may be fractional. Only the capacity refusal carries these fields; every other refusal is matched on its text and close reason exactly as before.

Changes

  • ErrorMessage gains optional error_type and retry_after; SignalingChannel copies them onto the ServerError that rejects the join (errorType, retryAfter).
  • StreamSession.connect() wraps the existing pRetry(...) call in a small loop. shouldRetry has one new early return: a capacity refusal with a usable retry_after is handed straight back (p-retry still tears the socket down), and the loop waits retry_after ±20 % jitter, floored at 1 s, then dials again. The 1 s floor keeps a tiny retry_after from re-dialling into the refused socket's own close event. Everything else p-retry did is untouched: a capacity close or error without error_type never enters the loop and keeps today's backoff; terminal and permanent refusals are unchanged; the reconnect after an established session drops is unchanged and does not read retry_after.
  • New connect option capacityRetryBudgetMs (default 60 000): how long after its first dial a connect() keeps re-dialling capacity refusals. A wait is only taken if it would end within the budget; after that connect() rejects with the server's own error (message, errorType, retryAfter), which still matches the capacity-refusal regex pinned by the existing tests.
  • retries and the budget: capacity waits are bounded by the budget alone and do not count against retries; retries: 0 turns them off as well, so it still dials exactly once. (Simpler than also capping waits by an explicit retries: N, which the budget already bounds.)
  • New connect option onCapacityWait, called with { retryAfterMs, attempt } before each wait (attempt counts the waits of this connect() from 1). It is a connect option rather than a client event because it can only fire while connect() is pending, before the client object exists. New exported CapacityWait type.
  • README: a short note on the option and the callback.

Tests

Five new cases in the existing harnesses:

  • StreamSession: a capacity error with retry_after: 5 emits capacityWait once and re-dials after 4 to 6 s (not at the 1 s backoff floor), and the re-dial connects; capacityRetryBudgetMs: 7_000 stops after the first wait and rejects with the server's error; connectRetries: 0 dials once and never waits.
  • realtime.connect options: onCapacityWait fires with the jittered wait and the re-dial reuses the same URL.
  • SignalingChannel: error_type / retry_after land on the rejecting error.

Old-server behaviour (1013 "Try Again Later" without fields keeps the backoff) and the terminal session limit are covered by the existing tests, which are unchanged and still pass.

Verification

Run from packages/sdk:

Command Result
tsdown (build) Build complete
node tests/built-package.mjs passed
biome lint Checked 86 files, no issues
biome check Checked 86 files, no issues
tsc --noEmit passed
vitest unit --run 15 files, 386 tests passed (5 new)

Compatibility

Additive and backward compatible: no new required options, and servers that send neither field get exactly today's behaviour. No version bump or release in this PR.


Note

Medium Risk
Changes realtime connect retry orchestration (handshake path), but behavior is additive, backward compatible, and covered by new unit tests.

Overview
Realtime connect() now treats server capacity refusals (error_type: "capacity" plus retry_after on the signaling error message) differently from generic 1013 "Try Again Later" closes: it waits the server’s delay (±20% jitter, at least 1s), re-dials within capacityRetryBudgetMs (default 60s), and surfaces each wait via optional onCapacityWait ({ retryAfterMs, attempt }). retries: 0 still means a single dial and also disables these capacity re-dials.

SignalingChannel copies error_type / retry_after onto join failures; StreamSession wraps the existing p-retry loop so capacity errors bypass exponential backoff but stay bounded by the budget. Servers that omit the new fields keep today’s backoff behavior. README and exported CapacityWait type document the options.

Reviewed by Cursor Bugbot for commit d64c091. Bugbot is set up for automated code reviews on this repo. Configure here.

@pkg-pr-new

pkg-pr-new Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@decartai/sdk@221

commit: d64c091

…etryBudgetMs and onCapacityWait

When the server refuses a dial for lack of capacity it now sends
error_type "capacity" and retry_after (seconds). connect() hands that
refusal out of p-retry and waits retry_after (±20 % jitter, at least
1 s) before dialling again, for as long as the new capacityRetryBudgetMs
option allows (default 60 s since the first dial). Those waits are
bounded by the budget, not by retries, but retries: 0 still dials once.
onCapacityWait reports each wait to the app. Servers that send neither
field keep today's backoff.
@AdirAmsalem
AdirAmsalem force-pushed the conductor/sdk-capacity-retry-after branch from 0b666a2 to d64c091 Compare October 8, 2026 12:38
@AdirAmsalem
AdirAmsalem marked this pull request as ready for review October 8, 2026 14:44
@AdirAmsalem
AdirAmsalem merged commit 9ae5cd4 into main Oct 11, 2026
5 checks passed
@AdirAmsalem
AdirAmsalem deleted the conductor/sdk-capacity-retry-after branch October 11, 2026 07:13
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.

1 participant