Skip to content

fix: harden RPC error classification, nonce validation, and builder type honesty - #466

Closed
Teescom wants to merge 1 commit into
conduit-protocol:mainfrom
Teescom:fix/456-459-error-handling-and-type-safety
Closed

fix: harden RPC error classification, nonce validation, and builder type honesty#466
Teescom wants to merge 1 commit into
conduit-protocol:mainfrom
Teescom:fix/456-459-error-handling-and-type-safety

Conversation

@Teescom

@Teescom Teescom commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR resolves four Stellar Wave issues (#456, #457, #458, #459) that all fall under the same theme: error classification and type honesty. Each one made the SDK either misreport failures or silently mask caller bugs.

Closes #456, Closes #457, Closes #458,Closes #459


Changes

#456RateLimitError.fromRpcError() conflates HTTP 503 with 429

Problem: HTTP 503 (Service Unavailable) and 429 (Too Many Requests) were wrapped into the same RateLimitError, so a consumer catching RateLimitError to back off and retry the same endpoint would retry forever against a node that is actually down.

Fix:

  • Added a new exported RpcServiceUnavailableError class in src/errors.ts (mirrors RateLimitError's shape, including retryAfterMs parsed from Retry-After).
  • RateLimitError.fromRpcError() now returns a RateLimitError for 429 (and JSON-RPC 429/-32029) and a RpcServiceUnavailableError for 503, with a distinct message telling callers to consider failing over to a different RPC URL.
  • The internal RPC retry wrapper (createRpcServer in src/soroban.ts) now only backoff-retries genuine RateLimitError instances — a 503 fails fast so it surfaces immediately instead of being retried against a dead endpoint.
  • Exported RpcServiceUnavailableError from the package root (src/index.ts).

#457catchNetworkError() misclassifies unrelated TypeErrors as network errors

Problem: catchNetworkError() substring-matched the entire error text against /fetch|network|connect|.../i. A programming bug like TypeError: Cannot read properties of undefined (reading 'connect') was reported to the caller as a network outage, hiding the real bug.

Fix (src/soroban.ts):

  • TypeErrors are only reclassified when they are provably transport failures:
    • The canonical fetch/axios messages, matched exactly (never substring): fetch failed (undici), Failed to fetch (Chromium), Network Error (axios), Load failed (Safari).
    • Or the error — or its nested cause chain (where Node's undici hides the real errno) — carries a network errno code (ECONNREFUSED, ENOTFOUND, ETIMEDOUT, ENETUNREACH, ERR_NETWORK, UND_ERR_*, ERR_CONN_*, etc.).
  • Everything else, including unrelated TypeErrors, is re-thrown unchanged.

#458NonceManager.toSafeBigInt() silently coerces unparseable nonces to 0n

Problem: new NonceManager({ startNonce: 'not-a-number' }) silently started at 0n, masking a caller bug (e.g. a stringified undefined or a malformed network value) as an explicit 0.

Fix (src/nonce/NonceManager.ts): toSafeBigInt() now throws a descriptive error for unparseable strings — and for empty strings, which BigInt('') would otherwise coerce to 0n — consistent with the constructor's other descriptive guards. Valid numeric strings ('42', '9007199254740993') still work, and isNonceValid() semantics are unchanged.

#459StreamBuilder.build() claims ratePerSecond is always a string

Problem: .ratePerSecond(500).build() produced a runtime number while the declared return type promised string (because bigintSafeStringify() only stringifies bigint values). Callers trusting the type (.trim(), string concatenation) hit runtime errors TypeScript couldn't catch.

Fix (src/builder.ts): build() now coerces a numeric ratePerSecond to its string form, so the runtime value matches the declared ratePerSecond?: string type. The public API (ratePerSecond(val: number | bigint)) is unchanged, and bigint inputs still stringify as before.


Tests

  • src/tests/rate-limit-error.test.ts — 503 → RpcServiceUnavailableError (distinct from RateLimitError, parses Retry-After), plus a 429-vs-503 distinguishability test.
  • src/tests/soroban-rate-limit.test.ts — a 503 thrown through simulateReadOnly surfaces as RpcServiceUnavailableError without being retried (mock called exactly once).
  • src/tests/soroban-network-error.test.ts (new) — 10 cases covering genuine network errors (fetch failed, nested ECONNREFUSED cause, browser Failed to fetch, ENOTFOUND, axios ERR_NETWORK) vs. unrelated TypeErrors mentioning connect/fetch, plus passthrough of already-classified errors.
  • src/tests/nonce-concurrent.test.ts — unparseable and empty-string nonces throw descriptive errors; numeric/bigint/valid-string nonces still work.
  • src/tests/builder.test.ts — numeric ratePerSecond now serialises to '500' (string) and survives JSON.stringify.

Verification: npm run typecheck ✅ · npm run lint ✅ · npm test ✅ (738 passed, 2 skipped).


Docs & Changelog

  • docs/api.md — updated the RPC wrapper note: 429 is retried with backoff; 503 is not retried and surfaces as RpcServiceUnavailableError.
  • CHANGELOG.md — documented all four fixes under [Unreleased] → Fixed.

Notes for reviewers

  • The fromRpcError() return type widened from RateLimitError | null to RateLimitError | RpcServiceUnavailableError | null — this is the intended behavior change (503 is no longer a RateLimitError), but code that annotated the result strictly as RateLimitError | null will need updating.
  • All call sites (throw RateLimitError.fromRpcError(err) ?? err) keep working unchanged; for a 503 they now throw the typed RpcServiceUnavailableError instead.

…ype honesty

Resolve four Stellar Wave issues:

- conduit-protocol#456: RateLimitError.fromRpcError() no longer conflates HTTP 503 with 429.
  A 503 is now reported as a distinct exported RpcServiceUnavailableError,
  and the internal RPC retry wrapper only backoff-retries genuine
  RateLimitErrors so callers can fail over to another RPC URL instead of
  retrying a dead endpoint.
- conduit-protocol#457: catchNetworkError() only reclassifies errors that are provably
  transport failures (canonical fetch/axios messages or a network errno code
  on the error or its nested cause) instead of substring-matching the whole
  error text, so unrelated TypeErrors are no longer masked as network outages.
- conduit-protocol#458: NonceManager.toSafeBigInt() throws a descriptive error for
  unparseable nonce strings instead of silently coercing them to 0n.
- conduit-protocol#459: StreamBuilder.build() stringifies a numeric ratePerSecond so the
  runtime value matches the declared `ratePerSecond?: string` return type.

Adds regression tests for all four fixes and updates docs + CHANGELOG.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@drips-wave

drips-wave Bot commented Aug 25, 2026

Copy link
Copy Markdown

@Teescom 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

@Jaydbrown

Copy link
Copy Markdown
Contributor

Thanks for the contribution here — squash-merging this now. Any follow-ups we'll track in a fresh issue. 🚀

Jaydbrown added a commit that referenced this pull request Aug 26, 2026
…ype honesty (#466)

Co-authored-by: Teescom <Teescom@users.noreply.github.com>
@Jaydbrown

Copy link
Copy Markdown
Contributor

Merged into main as b3582df — hand-rebased onto current main since this fork doesn't have "Allow edits from maintainers" on. All of it landed: the RpcServiceUnavailableError split, the tighter catchNetworkError(), the NonceManager unparseable-string error, and the StreamBuilder.build() numeric-ratePerSecond coercion, plus the new tests. Attributed to you via Co-authored-by. Closing — thanks, @Teescom! 🙏

@Jaydbrown Jaydbrown closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment