SolidPeer

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


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.


// 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