Skip to content

feat(stellar): add connect and request timeouts to the Horizon and RP… - #223

Merged
truthixify merged 1 commit into
wraith-protocol:developfrom
aratass:feat/issue-202-request-timeouts
Sep 25, 2026
Merged

truthixify merged 1 commit into
wraith-protocol:developfrom
aratass:feat/issue-202-request-timeouts

Conversation

@aratass

@aratass aratass commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Closes #202.

Summary

The Horizon and Soroban RPC clients retry failed requests, but an attempt that never resolved held the whole call, and any scan waiting on it, forever. This PR gives every attempt a deadline:

  1. Configurable connect and request timeouts. createHorizonClient() and createRpcClient() take timeouts: { connectMs, requestMs }, and each call can override them (horizon.get(path, { timeouts }), rpc.request(method, path, body, { timeouts })). 0 turns a timeout off.
  2. Timed-out fetches are aborted before retrying or failing over. Each attempt has its own AbortController. When a timeout fires, the request is aborted first; only then does the Horizon client retry, or the RPC client count the failure toward its circuit breaker and fail over.
  3. A typed error that keeps the endpoint and attempt. New RPCTimeoutError (WRAITH/NETWORK/RPC_TIMEOUT, a WraithNetworkError) with url, endpoint, attempt, phase and timeoutMs. When retries run out, RPCRetryExhaustedError keeps the last attempt's error on cause, so the endpoint and attempt that timed out are not lost.
  4. Fake-fetch tests with fake timers for the timeout, retry and failover paths of both clients.

Design

Option Default Covers
connectMs 10 000 ms Everything up to the response headers: DNS, connecting, TLS, sending the request and waiting for the endpoint to answer. fetch() has no separate hook for the handshake, so this is the closest portable connect timeout.
requestMs 30 000 ms The whole attempt, including reading the response body.
  • src/chains/stellar/timeouts.ts holds resolveTimeouts() and AttemptDeadline, which both clients use.
    • resolveTimeouts() layers defaults, then client config, then the call's options. A negative, NaN, infinite or too-large value (over setTimeout's 2 147 483 647 ms) throws a RangeError when the client is created or the call is made.
    • AttemptDeadline owns the AbortSignal passed to fetch. When a timer fires, it rejects the attempt with RPCTimeoutError and aborts the signal.
  • Fetches that ignore the signal still time out. The fetch and the body read are raced against the deadline, so a fetch that ignores the signal (some polyfills do) cannot hang the attempt. If the abort surfaces as an AbortError, the attempt reports the timeout instead.
  • No leftover timers. Timers are cleared as soon as an attempt settles, before any backoff sleep, and again in a finally, so none outlives a request. The tests assert vi.getTimerCount() === 0.
  • Where AbortController does not exist, the race still enforces the timeout.

One behaviour fix in createRpcClient().

  • Before: the client called markHealthy() as soon as the headers of a 200 arrived, before reading the body. An endpoint that sent headers and then stalled reset its failure count on every attempt. It never tripped the circuit breaker, so request timeouts on such an endpoint could never cause a failover.
  • Now: the endpoint is marked healthy only after the body has been read.
  • The test "times out a body that stalls after the headers and keeps counting it as a failure" fails without this change.

Defaults. Both clients now time out by default instead of waiting forever.

  • This is listed under Changed in the CHANGELOG and explained in MIGRATING.md.
  • timeouts: { connectMs: 0, requestMs: 0 } restores the old behaviour.
  • Horizon holds a POST /transactions response until the transaction is in a ledger, so the docs show a longer per-call timeout for submissions.

Error shape

import { RPCRetryExhaustedError, RPCTimeoutError } from '@wraith-protocol/sdk';

try {
  await rpc.request('POST', '/', body);
} catch (error) {
  if (error instanceof RPCRetryExhaustedError && error.cause instanceof RPCTimeoutError) {
    error.cause.endpoint; // 'https://rpc-fallback.example'
    error.cause.attempt; // 4
    error.cause.phase; // 'connect' | 'request'
    error.cause.timeoutMs; // 5000
  }
}
  • cause stays out of serialised output. It is non-enumerable and left out of toJSON(), the same as on the wallet errors from [Wave 9] Add wallet adapter failure and disconnect conformance tests #214.
  • Existing calls are unchanged. RPCRetryExhaustedError's constructor gains an optional fourth options argument.
  • Failover reason. For a timeout, the RPC client's endpointFailover reason reads Timeout on <endpoint>: connect timeout of 5000ms.

Tests

test/chains/stellar/request-timeouts.test.ts (24 tests) uses a scripted fake fetch with fake timers.

  • Fake fetch behaviours: it can hang until aborted (like a real fetch to an unreachable host), ignore the abort entirely, answer after a delay, or send headers and then stall mid-body.
  • Recording: it records each call's signal, and whether earlier calls were already aborted when the next one started.

What the tests cover:

  • RPC client:
    • a hung attempt is aborted at exactly connectMs (not at connectMs - 1) and retried;
    • repeated timeouts trip the breaker and fail over, with every primary attempt aborted before endpointFailover fires;
    • when all endpoints time out, the client throws RPCRetryExhaustedError whose cause names the last endpoint and attempt 4;
    • a stalled body hits requestMs and still fails over;
    • a fetch that ignores abort still times out;
    • per-call overrides work, and 0 disables a timeout;
    • an invalid per-call value rejects before any fetch;
    • timers are cleared after success and before backoff.
  • Horizon client: the same paths for GET and POST. That includes a slow submission that succeeds with a per-call override, and the signal being passed to fetch.
  • Helpers:
    • resolveTimeouts() defaults, layering and validation;
    • AttemptDeadline reports the timeout rather than an AbortError.
  • test/errors.test.ts:
    • RPCTimeoutError's place in the hierarchy, its code, fields, context, toJSON() and describe();
    • RPCRetryExhaustedError keeps cause non-enumerable and out of JSON.

I also broke the implementation on purpose to check that the tests catch real regressions. Each change below makes at least one test fail:

Deliberate break Tests that fail
mark the endpoint healthy before reading the body (the old behaviour) 1
never abort the controller 7
await fetch without racing the deadline 2
skip dispose() in the RPC client 2
skip dispose() in the Horizon client 2
leave the timers armed during the backoff sleep 2
keep the connect timer after the headers arrive 2
report the raw AbortError instead of the timeout 1

Docs

  • docs/chains/stellar-request-timeouts.md covers the options, what happens on a timeout and the error fields, with examples.
  • docs/errors.md adds RPCTimeoutError to the hierarchy and the network table, and explains cause on RPCRetryExhaustedError.
  • CHANGELOG.md has entries under Added and Changed; MIGRATING.md has a new section.
  • etc/sdk.api.md and etc/sdk-stellar.api.md are regenerated.

Verification

Based on develop @ d7cfec6, with no new dependencies. Run locally with pnpm 10 on Node 22, in CI's order.

CI step develop (before) this branch (after)
pnpm run format:check exit 0, "All matched files use Prettier code style!"
pnpm build exit 0 exit 0
pnpm api:check exit 0, 6/6 "API Extractor completed successfully"
pnpm test exit 0. Files: 81 passed, 2 skipped (83). Tests: 1391 passed, 5 skipped (1396). sdk-svelte: 6 files, 10 tests passed exit 0. Files: 82 passed, 2 skipped (84). Tests: 1418 passed, 5 skipped (1423). sdk-svelte: 6 files, 10 tests passed
pnpm test:exports exit 0, "entry point smoke tests passed"

The 27 extra tests are 24 in request-timeouts.test.ts and 3 in errors.test.ts.

Bundle size. pnpm size exits 0 on both.

Entry develop (kB) branch (kB) Δ (kB) limit (kB)
Root ESM / CJS 32.93 / 140.36 33.15 / 140.68 +0.22 / +0.32 36.1 / 159.7
Stellar ESM / CJS 27.04 / 35.49 27.84 / 36.23 +0.80 / +0.74 30.5 / 39.5
EVM ESM / CJS 23.90 / 127.76 23.90 / 127.81 0 / +0.05 27.5 / 145.9
Solana ESM / CJS 17.22 / 26.60 17.17 / 26.86 −0.05 / +0.26 19.8 / 29.8
CKB ESM / CJS 20.58 / 129.29 20.47 / 129.52 −0.11 / +0.23 23.6 / 148.1
Vault ESM / CJS 1.93 / 2.07 1.93 / 2.07 0 / 0 2.3 / 2.4

🤖 Generated with Claude Code

https://claude.ai/code/session_01UhuzUyVKtcr7g1gbqgu3Mg

…C clients

createHorizonClient() and createRpcClient() now give every attempt a
connect timeout (response headers, default 10 s) and a request timeout
(whole attempt including the body, default 30 s), configurable per client
and per call, with 0 to turn either off.

A timed-out attempt is aborted through its own AbortController before the
client retries or fails over, and the fetch and body read are raced
against the deadline so a fetch that ignores the signal cannot hang a
scan. Timeouts throw the new RPCTimeoutError (WRAITH/NETWORK/RPC_TIMEOUT)
with the URL, endpoint, attempt number, phase and timeout; when retries
run out, RPCRetryExhaustedError keeps the last attempt's error on cause.

The RPC client now marks an endpoint healthy only after the body has been
read, so an endpoint that sends headers and then stalls still trips the
circuit breaker.

Closes wraith-protocol#202
@drips-wave

drips-wave Bot commented Sep 25, 2026

Copy link
Copy Markdown

@aratass 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! 🚀

Learn more about application limits

@truthixify
truthixify merged commit 8ea5dc4 into wraith-protocol:develop Sep 25, 2026
16 checks passed
@truthixify

Copy link
Copy Markdown
Contributor

Merged. Thanks @aratass. Timeout, abort, retry, and failover behavior are well covered.

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.

[Wave 9] Add request timeouts to Horizon and Soroban RPC clients

3 participants