Errors
The hierarchy
import {
ExplorerError,
HTTPError,
TransportError,
AuthError,
RateLimitError,
PlanRestrictedError,
NotFoundError,
UnsupportedChainError,
UnsupportedOperationError,
UnknownProviderError,
AddressChainMismatchError,
} from "@agntn/explorers";
| Class | When | Extra |
|---|---|---|
HTTPError | a response whose error status no class below claims | statusCode, reason, rawUrl, body |
TransportError | no response at all: refused, unresolved host, reset, timeout, abort | reason, code, rawUrl |
AuthError | credentials missing or rejected, 401, 403 | |
RateLimitError | 429 or an explorer saying so in words | retryAfter in seconds |
PlanRestrictedError | valid key, but the read needs a paid tier | |
NotFoundError | 404, an unknown address, hash, block or an ENS name that doesn't resolve | |
UnsupportedChainError | this provider doesn't serve that chain | chain |
UnsupportedOperationError | this provider doesn't have that operation | operation |
UnknownProviderError | create() or resolveProvider() asked for a name nobody registered | |
AddressChainMismatchError | the address belongs to another chain family, caught before any request | address, chain, matches |
Every one extends ExplorerError and carries provider, so a catch can be as broad or as narrow as you like. AddressChainMismatchError leaves provider empty, because no provider was asked: a Bitcoin address with chain: "ethereum" fails on its format, and matches lists the chains that format does fit. UnknownProviderError lists the names that are registered, so a typo takes one look to fix, not a search.
try {
await provider.getTxDetail?.(hash, "ethereum");
} catch (error) {
if (error instanceof RateLimitError) {
console.log(error.retryAfter); // seconds, when the explorer said
}
if (error instanceof NotFoundError) {
// the hash is unknown to this explorer
}
}
normalizeError
Providers don't write their own classification, they'd all get it slightly differently wrong. normalizeError(error, provider, url) turns whatever the shared client, a provider's own check or a custom provider's ofetch call threw into the hierarchy: 404 or "not found" in the message becomes NotFoundError, 429 or "rate limit" becomes RateLimitError, 401, 403 or "unauthorized" becomes AuthError, any other status becomes HTTPError, and an ExplorerError passes through untouched. A request that never got a response is checked first and becomes TransportError. Its reason is the message of the innermost cause, like connect ECONNREFUSED 203.0.113.7:443, and code carries ECONNREFUSED, ENOTFOUND, TimeoutError and the like, which is how a refused connection differs from a timeout. Anything else stays a plain ExplorerError with the original message, plus the URL when there is one.
An HTTPError message ends with the explorer's own reason when the body gives one, like HTTP 400 from https://blockstream.info/api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa/utxo: Too many unspent outputs. The reason is a plain-text body, or the first reason, detail, message, error or title field of a JSON one, at the top or one level down. It comes out as one line of at most 200 characters, with no control bytes a terminal would act on. An HTML error page gives no reason, so that message stops at the URL. The quoted text also sits alone in reason, for a page or a log line that leaves the URL out. The CLI, MCP, Pi and OMP show only the message, and this is the part that tells you what to change.
Keys never leak
ExplorerError's constructor runs every message through one redaction: api_key, apikey, key, secret and token query parameters become REDACTED. HTTPError does the same to rawUrl and to the response body, in case the explorer echoed the request back. Helius carries its key as api-key in the query, which is exactly the case this guards. Log an error, paste it in an issue, fine.
Rate limits
The HTTP client never retries, so a 429 is classified before anything waits. Provider then retries that same backend twice: it honors Retry-After when the explorer sent one, otherwise it backs off from one second. JSON answers that say "rate limit" in a 200 are treated the same as HTTP 429, because Etherscan dresses those up as ordinary envelopes. After that, withProvider() still moves an automatic selection once to the next provider that serves the chain, for RateLimitError, PlanRestrictedError, TransportError other than your own abort, or an HTTPError with a 5xx status. An explicit provider never switches backends.