SolidPeer — Error Reference
Source-of-truth catalog of every error a customer can see from the gateway. Drawn from gateway/src/listeners/{http,ws}.ts + gateway/src/auth/token.ts. Intended as raw material for the eventual public API docs — reads as a developer reference today, not yet polished customer copy.
Status
Implemented and live-verified on the regtest deploy as of 2026-05-04. Catalog is stable, fully wired. account_status is JOINed in by TokenStore.lookup from the accounts table and cached alongside the token blob; HTTP and WS upgrade both check it after the token-validity check.
1. Design principles
- Same
reasonstring on either transport. A client writes one handler keyed onreasonand it works against bothPOST /<system>/<network>/...andwss://.../{system}/{network}/.... - HTTP keeps
error: stringfor backward compat. Older clients readingbody.errorkeep working; the new structured fields are siblings on the same object. - WS uses spec-compliant JSON-RPC codes in the
-32000..-32099server-defined range, not QuickNode-style positive HTTP-mirror integers (which violate JSON-RPC 2.0). The HTTP-equivalent status is inerror.data.http_statusfor clients porting from QuickNode-style mental models. - Headers mirror the body. HTTP responses set
X-RateLimit-Reason,X-Upstream-Status, etc., carrying the same string the JSON body'sreasondoes. Either source is sufficient. - No internal state on the wire. Error responses never include account balances, suspended_reason text, internal IDs, or other fields visible to intermediate proxies. Customer-specific context lives behind the authenticated
/meendpoint (TODO).
2. Envelope shapes
HTTP
{
"error": "<human-readable message — preserved for backward compat>",
"reason": "<machine-readable failure-class string>",
"...": "<failure-specific extras: limit, remaining, retry_after_ms, system>"
}
Status code is the HTTP status. Some failures also set headers (see §3 for the per-failure list).
WSS / JSON-RPC
{
"jsonrpc": "2.0",
"id": <client-supplied request id, echoed back>,
"error": {
"code": <-32000..-32099, see §4>,
"message": "<human-readable, mirrors HTTP body.error>",
"data": {
"reason": "<same string as HTTP body.reason>",
"http_status": <equivalent HTTP status code, for QuickNode-mental-model porters>,
"...": "<failure-specific extras>"
}
}
}
Pre-billing failures during the WS upgrade handshake (auth, system/network resolution) return plain HTTP responses (no WS frame) with the HTTP envelope above — the connection never upgrades, so the client sees a regular HTTP response.
3. Catalog
Failure classes are grouped by what they tell the client to do. Within each group, rows are HTTP-status-first then WS-code-first.
Authentication & request shape (don't retry — fix the request)
| reason | HTTP | WS code | When it fires |
|---|---|---|---|
unknown_system |
404 | -32601* |
URL system segment isn't bchn / fulcrum / chaingraph |
unknown_network |
404 | -32601* |
URL network segment isn't mainnet / chipnet / testnet4 / regtest |
missing_auth |
401 | (HTTP at upgrade) | No token in URL path AND no Authorization: Bearer <token> header |
invalid_token |
401 | (HTTP at upgrade) | Token doesn't exist, is revoked, OR is scoped to a different system/network |
token_expired |
401 | (HTTP at upgrade) | Token's expires_at has passed. Distinct from invalid_token so clients can route to a "rotate credential" UX rather than a generic auth-failure surface. Long-lived service-account tokens leave expires_at NULL. |
unparseable |
400 | -32700 |
Body isn't valid JSON / not a JSON-RPC envelope |
invalid_request |
(n/a — HTTP requires method extraction first) | -32600 |
WS frame parses as JSON but isn't a valid JSON-RPC request |
* WS upgrade-time errors return plain HTTP — the connection never reaches a WS frame, so the client sees the HTTP envelope (§2).
Authorization (don't retry — adjust scope or upgrade)
| reason | HTTP | WS code | data fields | When it fires |
|---|---|---|---|---|
preflight |
403 | -32601 |
http_status: 403 |
Per-system parameter validation failed (e.g. BCHN deny-list, Chaingraph complexity score, parameter shape) |
method_denied |
403 | -32601 |
http_status: 403 |
Method requires write permission but the token's allowed_methods doesn't include it (read-only-token writing) |
method_not_in_allowlist |
403 | -32601 |
http_status: 403 |
Method isn't in the gateway's per-system allowlist at all (no cost record exists) |
origin_denied |
403 | (HTTP at upgrade) | — | Token has allowed_origins set and the request's Origin (or Referer fallback) hostname doesn't match. Match shapes: exact (api.example.com) or wildcard subdomain (*.example.com matches foo.example.com and a.b.example.com, but NOT the bare apex example.com — list both example.com and *.example.com to allow apex + any subdomain). Strict-when-set: requests with no Origin/Referer also fail this check. Server-to-server tokens should leave allowed_origins unset. |
subscriptions_unsupported |
(n/a) | -32601 |
http_status: 501, system |
Client tried to subscribe on a system that doesn't support it. Currently fires only for bchn (BCHN doesn't expose user-facing subs; ZMQ is operator-only). Both fulcrum (Electrum-style methods ending .subscribe) and chaingraph (GraphQL subscription operations) have working subscription paths. |
Rate / capacity (retry with backoff)
| reason | HTTP | WS code | data fields | Headers (HTTP) | When it fires |
|---|---|---|---|---|---|
rate |
429 | -32029 |
limit, remaining, retry_after_ms, http_status: 429 |
X-RateLimit-Reason: rate, X-RateLimit-Limit, X-RateLimit-Remaining, X-Retry-After-Ms |
Per-token RPS bucket empty (Redis Lua token bucket) |
concurrent |
429 | (HTTP-only) | — | X-RateLimit-Reason: concurrent |
Per-account in-flight request cap exceeded (HTTP) or per-account open WS connections cap exceeded (WS upgrade) |
retry_after_ms = ceil(1000 / max(cap, 1)) — the soonest the next bucket token will refill. cap=0 (a deliberately-disabled token) clamps the divisor to 1 instead of producing Infinity.
Balance (top up, upgrade tier, or wait for renewal)
| reason | HTTP | WS code | data fields | Headers (HTTP) | When it fires |
|---|---|---|---|---|---|
balance |
429 | -32028 |
http_status: 429 |
X-RateLimit-Reason: balance |
Reservation against the account's CC balance failed (insufficient funds for the request's reserve estimate) |
suspended |
403 | -32027 |
http_status: 403 |
X-Account-Status: suspended |
Account is suspended (orthogonal to balance/expiry — see BILLING.md). Operator-only state; clients should surface "contact support" rather than a self-serve CTA. |
expired |
403 | -32026 |
http_status: 403 |
X-Account-Status: expired |
Billing cycle ended without renewal. Self-serve CTA: "renew" or "upgrade tier." |
Note on Retry-After: the rate-limit refill window is sub-second on most caps (cap=50 → 20ms). RFC 7231 Retry-After is integer seconds, which would round to 0 or 1 and mislead clients. The custom X-Retry-After-Ms header carries the precise value alongside.
Upstream (retry; possibly against a different backend)
| reason | HTTP | WS code | data fields | Headers (HTTP) | When it fires |
|---|---|---|---|---|---|
no_upstream |
503 | -32030 |
http_status: 503, system |
X-Upstream-Status: unavailable |
No healthy backend in the pool for the requested system. Operator-actionable: a backend deploy/failover problem. |
upstream_error |
502 | -32031 |
http_status: 502, system |
X-Upstream-Status: failed |
Backend was reachable but failed the request (timeout, 5xx from upstream, malformed response). Typically transient — retry usually succeeds against a different backend. |
The system field on these lets multi-system clients narrow scope: a Fulcrum outage with healthy BCHN should degrade gracefully rather than treating any 503 as a total service failure.
4. JSON-RPC code allocation
All in -32000..-32099 (the JSON-RPC 2.0 server-defined range):
| code | reason | HTTP analog |
|---|---|---|
-32024 |
token_expired |
401 |
-32025 |
origin_denied |
403 |
-32026 |
expired |
403 |
-32027 |
suspended |
403 |
-32028 |
balance |
429 |
-32029 |
rate |
429 |
-32030 |
no_upstream |
503 |
-32031 |
upstream_error |
502 |
Standard JSON-RPC codes (also used):
| code | reason | HTTP analog |
|---|---|---|
-32600 |
invalid_request |
400 |
-32601 |
preflight / method_denied / method_not_in_allowlist / subscriptions_unsupported |
403 / 501 |
-32700 |
(parse error — body isn't valid JSON) | 400 |
The -32601 overload is intentional. The JSON-RPC spec lumps every "method not callable" condition into one code; the data.reason field is what lets clients distinguish the four flavors above.
5. Authentication shapes
Both HTTP and WS accept the token in either form (path takes precedence when both are supplied):
URL path: /<system>/<network>/<token>
Header: Authorization: Bearer <token> (URL is /<system>/<network>)
Bearer scheme is matched case-insensitively per RFC 6750 §2.1. Bearer with no token, whitespace-only path segment, malformed scheme (e.g. Basic ...), and missing both forms all return reason: "missing_auth".
Browser caveat: the WebSocket API doesn't expose handshake headers, so browser-origin WS clients use the URL form. Server-to-server clients can use either; the header form keeps the secret out of access logs and proxy caches.
6. Recommended client handling
// Pseudocode — same shape works for HTTP and WSS
const reason = response.reason ?? response.error?.data?.reason;
switch (reason) {
case 'rate':
// wait response.retry_after_ms (or data.retry_after_ms), retry
case 'balance':
// surface "out of credits — top up or upgrade"; no auto-retry
case 'suspended':
// surface "account suspended — contact support"; no auto-retry
case 'expired':
// surface "billing cycle ended — renew or upgrade"; no auto-retry
case 'no_upstream':
case 'upstream_error':
// exponential backoff, retry; system field tells you which subsystem
case 'concurrent':
// brief backoff, retry — the in-flight cap clears as your other
// requests complete
case 'preflight':
case 'method_denied':
case 'method_not_in_allowlist':
case 'origin_denied':
// bug in client request shape OR token scope — don't retry; fix
// the call, upgrade the token's scope, or rotate to an unrestricted
// server-to-server token if the client can't send Origin
case 'unknown_system':
case 'unknown_network':
case 'unparseable':
case 'invalid_request':
// bug in client URL or body — don't retry
case 'missing_auth':
case 'invalid_token':
// re-issue auth from your secret store; if token is genuinely
// revoked, prompt operator intervention
case 'token_expired':
// token TTL expired — rotate (mint a new token from the operator
// dashboard, or trigger your refresh-token flow if implemented)
default:
// unrecognized reason → fall back to HTTP status / WS code
}
7. Examples (live captures from the regtest deploy)
HTTP rate-limit (cap=2, third request in burst)
HTTP/1.1 429 Too Many Requests
X-RateLimit-Reason: rate
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
X-Retry-After-Ms: 500
Content-Type: application/json
{
"error": "rate limit exceeded",
"reason": "rate",
"limit": 2,
"remaining": 0,
"retry_after_ms": 500
}
HTTP balance exhausted
HTTP/1.1 429 Too Many Requests
X-RateLimit-Reason: balance
Content-Type: application/json
{"error":"insufficient balance","reason":"balance"}
HTTP unknown system / invalid token / missing auth
{"error":"unknown system","reason":"unknown_system"}
{"error":"invalid token / system or network not authorized","reason":"invalid_token"}
{"error":"missing auth — provide token in URL path or Authorization: Bearer header","reason":"missing_auth"}
WSS rate-limit
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32029,
"message": "rate limit exceeded",
"data": {
"reason": "rate",
"http_status": 429,
"limit": 2,
"remaining": 0,
"retry_after_ms": 500
}
}
}
WSS balance exhausted
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32028,
"message": "insufficient balance",
"data": { "reason": "balance", "http_status": 429 }
}
}
WSS preflight rejection (sendtoaddress on bchn)
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "method sendtoaddress not allowed on bchn",
"data": { "reason": "preflight", "http_status": 403 }
}
}
WSS method denied (sendrawtransaction with read-only token)
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32601,
"message": "method not allowed for token",
"data": { "reason": "method_denied", "http_status": 403 }
}
}
8. References
gateway/src/listeners/http.ts— HTTP error response sites +jsonErrorhelpergateway/src/listeners/ws.ts— WS upgrade-time + message-handler errors + typedbuildWs*helpers +JSONRPC_*code constantsgateway/src/auth/token.ts—extractRawToken(URL path / Bearer header dual-form auth)gateway/test/component/http-flow.test.ts— assertions onbody.reasonper failuregateway/test/ratelimit.test.ts— typed-builder unit tests + cross-cutting "every code distinct, all in -32xxx range, all carry http_status" guardsPRICING.md§3 / §6 — documents the "hard 429 at zero balance" contract and tier RPS caps that drive theratefailureBILLING.md— account state machine (active / expired / suspended) backing thesuspendedreason