Replace generic TalosAPIError request failures with an actionable, backward-compatible typed error hierarchy for @talos-protocol/sdk. Every typed error remains an instanceof TalosAPIError, so existing catch blocks keep working unchanged. New fields surface retry hints, validation issues, parsed x402 challenges, rate-limit counters, request-ids, and sanitized response headers for callers that want to react per failure mode.
This is a production-grade improvement for the Talos protocol: it preserves existing public behavior while addressing failure recovery, bounded concurrency, observability, secure defaults, and operational rollout.
- Generic
TalosAPIError(status, body, path)forces every consumer to introspectstatusand parsebodythemselves — no shared, stable contract for known failures. - Network and timeout failures were raw
Errors (or AggregateError wrappers) so retry logic couldn't safely rely onerr.isRetryable. - 5xx bodies, retry-after hints, and rate-limit counters were discarded on the client — observability had to be re-implemented per caller.
- Sensitive fields (
token,authorization,apiKey,signature, …) could leak through logs/payloads when callers stringified the error body.
All errors extend TalosAPIError so legacy catch (e: TalosAPIError) and rejects.toThrow(…) patterns keep working.
| Status | Type | Code | Retryable | Extra fields |
|---|---|---|---|---|
| 400 | TalosValidationError |
validation_error |
no | issues: string[] |
| 401 | TalosAuthenticationError |
authentication_error |
no | — |
| 402 | TalosPaymentError |
payment_error |
no | challenge?: { price, payee, token, … } |
| 403 | TalosForbiddenError |
forbidden |
no | — |
| 404 | TalosNotFoundError |
not_found_error |
no | — |
| 409 | TalosConflictError |
conflict_error |
no | data.detail? |
| 429 | TalosRateLimitError |
rate_limit_error |
yes | retryAfterMs, limit, remaining, resetAt |
| 500 | TalosServerError |
server_error |
no | — |
| 502/503/504 | TalosServerRetryableError |
server_error |
yes | — |
| network | TalosTransportError |
transport_error |
yes | cause? |
| abort/timeout | TalosTimeoutError |
timeout_error |
yes | — |
Every error exposes:
code— stable string discriminator forswitch-style handlingisRetryable— bounded retry hintretryAfterMs?— serverRetry-Afteralready normalized to msrequestId?—x-request-idfor log correlationheaders— sanitized subset (x-request-id,retry-after,www-authenticate,x-ratelimit-*)data— parsed JSON body, redacted + size-capped (≤ MAX_DATA_BYTES = 4096) at constructionbody— single-line, secrets redacted, capped atMAX_BODY_BYTES = 1024cause— original transport error if wrappedtimestamp— ISO 8601 string captured at constructiontoJSON()— bounded log-friendly projection (omitsbodyanddata)
sanitizeBody(raw)— parses JSON, runsredactSecrets, truncates toMAX_BODY_BYTES. Non-JSON bodies are collapsed to single line, truncated.redactSecrets(value)— recursive, cycle-safe (WeakSet). Replaces fields whose name matches/^(token|authorization|secret|api[_-]?key|password|cookie|signature|message|nonce|hash)$/iwith"[REDACTED]".snapshotHeaders(headers)— pulls a fixed safe subset (noauthorization, no cookies, no arbitrary user headers).parseRetryAfter(value)— accepts seconds or HTTP date, returns ms.parseX402Challenge(header)— requirespriceandpayee; rejects partial challenges before they reach the downstream/signcall.
-
Bounded timeout via
AbortController— opt-intimeoutMs;acquireTimeoutController()returns{ signal, dispose }and the request helper callsdispose()in afinallyso successful responses don't leaksetTimeouthandles. -
Bounded retry via
retry.maxAttempts— only retries idempotent (GET/HEAD) by default; honors serverRetry-After(clamped tomaxRetryAfterMs, default 60s) with exponential backoff (baseDelayMs * 2^(n-1), cappedmaxDelayMs, default 8s) and ±25% jitter. Hard cap of 8 attempts regardless of user input. Validation/auth/conflict/payment errors are never retried. -
onError/onRetryobservers — fire-and-forget callbacks for central logging/metrics.onErrorfires once per failed call with{ error, path, method, attempt, durationMs }. -
fetchinjection —TalosClientOptions.fetchallows swap-in middleware.globalThis.fetchis resolved lazily on each request sovi.stubGlobal("fetch", …)keeps intercepting in tests. -
Lazy
resolveFetch()— fixes a regression where eager capture at construction broke test mocks. -
Plain-object
mergeHeaders— preserves the legacytoHaveProperty("Authorization", …)test contract. -
purchaseServiceWithPaymentx402 flow now:- parses challenge via
parseX402Challenge(which requiresprice+payee); - guards
Number.isFinite(amount)soprice="abc"never feedsNaNto/sign; - delegates the signed retry to
request()so timeout/retry/typed errors apply uniformly.
- parses challenge via
-
errorFromResponsedispatcher — pure function mapping(status, body, headers)→ typed subclass; shared betweenrequest()andpurchaseServiceWithPayment's non-402 paths. -
classifyTransportError(cause, path)— wraps rawfetchrejections intoTalosTransportError/TalosTimeoutError, falls back toDOMException({name:"AbortError"})and message-based timeout heuristic. Original message is preserved so legacyrejects.toThrow("Aborted")assertions stay green.
New Error Handling section covering:
- The
TalosErrorCodediscriminator list with retries-per-type - Two example
catchblock patterns (instanceofandcodeswitch) - Retry, timeout, and observability configuration tables
- Privacy guarantees (
MAX_BODY_BYTES, redaction, no request bodies) - Migration / rollback narrative — explicitly "fully backward-compatible, no server-side migration required"
- All 29 pre-existing tests pass unchanged (legacy message strings, header shape,
instanceof TalosAPIError). - 31 new typed-error tests cover:
- Validation error parses server
issues[]and exposescode/requestId/data - 401 →
TalosAuthenticationError(≠ 403), 403 →TalosForbiddenError(withnot.toBeInstanceOf(TalosAuthenticationError)regression guard) - 404 →
TalosNotFoundError; 409 →TalosConflictError(withdata.detail) - 402 →
TalosPaymentErrorwith structuredchallenge{} - 429 →
TalosRateLimitErrorcapturingRetry-After+X-RateLimit-*headers - 500 →
TalosServerError(non-retryable); 503 →TalosServerRetryableError - Secret redaction: nested fields, cycle-safe
redactSecrets, oversized-body truncation - Transport classification:
ECONNREFUSED/AbortError/ message-based timeout - Bounded timeout (
vi.useFakeTimers+ AbortSignal listener) - Bounded retry:
- rate-limited GET retried until
maxAttempts - POST not retried by default (idempotent guard)
onRetry+onErrorobservers invoked correctlymaxAttemptshard-capped at 8 (user can request more, cap holds)- non-retryable errors fail immediately
- success after transient retry (returns 2nd mock)
- timer
clearTimeouton success (no leakedsetTimeouthandles)
- rate-limited GET retried until
- Helper coverage:
errorFromResponsematrix,sanitizeBody,redactSecretscycle,parseRetryAfter,parseX402Challenge,classifyTransportError,toJSON() - Backward-compat aliases: every typed error
instanceof TalosAPIError; legacy 502/503/504 message retains status code in string
- Validation error parses server
Closes #253
- All existing SDK tests pass unchanged (29/29)
- All new typed-error tests pass (31/31, total 60/60)
-
tscbuild is clean — no type errors - Verified manually with focused scripts:
- Confirmed every typed error is
instanceof TalosAPIError - Confirmed legacy message strings (
"Network error","Aborted","Request timeout","Invalid x402 challenge") are preserved on the new typed errors - Confirmed redaction removes bearer tokens, api keys, signatures, and Stellar secret seeds from
bodyanddata - Confirmed bounded retry never retries validation/auth/conflict/payment errors
- Confirmed
timeout?.dispose()runs infinally(success and failure paths)
- Confirmed every typed error is
TalosAPIErrorconstructor signature(status, body, path)is unchanged — existingcatch (e: TalosAPIError)andrejects.toThrow(…)patterns keep working.- All public SDK methods keep their signatures and return types.
- New
TalosClientOptionsfields (timeoutMs,retry,onError,fetch) are all optional with defaults that match the previous behavior. TalosAPIError.toJSON()stripsbodyanddatato avoid leaking large payloads through structured log sinks.- Rollback: revert the SDK version pin in
package.json. No server-side migration is required.
No visual redesign, no live deployment, no real credentials, no broad dependency upgrades.
- I have read the
CONTRIBUTING.mdguide. - My code follows the style guidelines of this project.
- I have commented my code in JSDoc, particularly on the typed error semantics.
- I have made corresponding changes to
packages/sdk/README.md. - My changes generate no new warnings or errors.
- I have added tests that prove the new typed hierarchy works.
- New and existing unit tests pass locally with my changes (60/60).
- This change is fully backward-compatible.