29fd0c9a0f
Aggressive cleanup of 359 exports across 153 files identified as having zero importers outside their declaring module: - 105 symbols deleted entirely (no internal uses either) - 254 symbols un-exported (still referenced file-locally; dropped the `export` keyword to shrink the public surface) - ~70 cascade cleanups of locals that became dead once their sole consumer was removed Notable shrinkage: - src/hooks/useShakespeare.ts: 626 \u2192 22 lines (unwired AI chat surface; only the ChatMessage type is consumed) - src/hooks/useTrending.ts: only useEventStats survives; trending feed hooks were never wired up - src/hooks/useTrustedCountryStats.ts: dead type re-exports removed - src/lib/bitcoin.ts: PSBT helpers \u2014 unused wallet feature scaffolding - src/lib/communityUtils.ts: unused NIP-72 moderation helpers - src/lib/extraKinds.ts, src/lib/colorUtils.ts: unused helpers - src/lib/logger.ts: bare debug/info/warn/error exports dropped; consumers use the `logger` object - src/lib/aiChatSystemPrompt.ts: trimmed to the DEFAULT_SYSTEM_PROMPT_TEMPLATE constant - src/components/music/MusicTrackRow.tsx: dead row component removed; only the skeleton is consumed src/hooks/useNostr.ts (intentional decoy) and src/i18n.ts (side-effect import) were preserved per their respective contracts.
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Esplora REST failover client
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The Esplora REST surface is supported by many backends besides mempool.space:
|
|
// Blockstream's reference implementation, mempool.space community mirrors
|
|
// (mempool.emzy.de, mempool.bitaroo.net, geographic mirrors), and self-hosted
|
|
// instances. They all speak the same `/address/...`, `/tx/...`,
|
|
// `/fee-estimates`, etc. paths — but availability varies, and rate limits
|
|
// (HTTP 429) on public instances are real.
|
|
//
|
|
// This module turns the configured `esploraApis` (an ordered array of URLs)
|
|
// into a single `esploraFetch(urls, path, init)` call that:
|
|
//
|
|
// 1. Tries each URL in order, with a per-attempt timeout (default 15s) so
|
|
// a hung connection — common when mempool.space has rate-limited you and
|
|
// is silently dropping the request — kills the request and fails over to
|
|
// the next URL instead of leaking the inflight fetch forever.
|
|
// 2. On network error / timeout / HTTP 429 / 5xx, parks the URL in a
|
|
// module-level cool-down map with exponential backoff (30s → 60s →
|
|
// 120s → 240s → 300s), then advances to the next URL.
|
|
// 3. On any 2xx (or non-retryable 4xx like 400/404), returns the response.
|
|
// 4. Successful responses reset the URL's failure count to zero.
|
|
// 5. If the caller's `signal` aborts, the active request is cancelled and
|
|
// the `AbortError` is propagated — we do NOT continue to other endpoints.
|
|
//
|
|
// Cool-down state is in-memory only — it lives for the session and is
|
|
// transparent to callers. The list of URLs itself is never mutated; we just
|
|
// skip ones whose cool-down hasn't expired.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Initial cool-down on first failure, in milliseconds. */
|
|
const INITIAL_COOLDOWN_MS = 30_000;
|
|
|
|
/** Maximum cool-down after repeated failures, in milliseconds. */
|
|
const MAX_COOLDOWN_MS = 300_000;
|
|
|
|
/**
|
|
* Default per-attempt timeout. Chosen to catch shadowban-style hangs
|
|
* (mempool.space's "absorb the request and never reply" rate-limit pattern)
|
|
* quickly, while still allowing genuinely slow responses on healthy endpoints
|
|
* to complete. A full address-with-paginated-txs response on a cold mempool
|
|
* is typically well under 10s.
|
|
*/
|
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
|
|
/** HTTP status codes that trigger failover + cool-down. */
|
|
const RETRYABLE_STATUS = new Set<number>([
|
|
408, // Request Timeout
|
|
425, // Too Early
|
|
429, // Too Many Requests
|
|
500, // Internal Server Error
|
|
502, // Bad Gateway
|
|
503, // Service Unavailable
|
|
504, // Gateway Timeout
|
|
]);
|
|
|
|
/** Per-URL cool-down state. */
|
|
interface EndpointState {
|
|
/** Earliest time (ms epoch) the endpoint may be retried. */
|
|
retryAt: number;
|
|
/** Consecutive failure count. Drives backoff length. */
|
|
failures: number;
|
|
}
|
|
|
|
/** Module-level map of URL → cool-down state. */
|
|
const state = new Map<string, EndpointState>();
|
|
|
|
/** Has this endpoint's cool-down elapsed? */
|
|
function isAvailable(url: string, now: number): boolean {
|
|
const s = state.get(url);
|
|
return !s || s.retryAt <= now;
|
|
}
|
|
|
|
/** Mark an endpoint as failed, extending its cool-down with exponential backoff. */
|
|
function markFailure(url: string, now: number): void {
|
|
const prev = state.get(url);
|
|
const failures = (prev?.failures ?? 0) + 1;
|
|
const backoff = Math.min(
|
|
INITIAL_COOLDOWN_MS * 2 ** (failures - 1),
|
|
MAX_COOLDOWN_MS,
|
|
);
|
|
state.set(url, { retryAt: now + backoff, failures });
|
|
}
|
|
|
|
/** Mark an endpoint as healthy. Clears any prior cool-down / failure count. */
|
|
function markSuccess(url: string): void {
|
|
if (state.has(url)) state.delete(url);
|
|
}
|
|
|
|
/** Strip a trailing slash so callers don't have to think about it. */
|
|
function normalize(url: string): string {
|
|
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
}
|
|
|
|
/**
|
|
* Options that control failover behaviour for a single `esploraFetch` call.
|
|
*/
|
|
interface EsploraFetchOptions extends Omit<RequestInit, 'signal'> {
|
|
/**
|
|
* Caller-supplied abort signal. When this signal aborts (e.g. a TanStack
|
|
* Query unmount), the inflight request is cancelled and an `AbortError`
|
|
* propagates to the caller — we do not continue to other endpoints.
|
|
*/
|
|
signal?: AbortSignal;
|
|
/**
|
|
* Per-attempt timeout in milliseconds. After this elapses, the current
|
|
* request is aborted and the endpoint is marked as failed; the next URL
|
|
* in the list is tried. Defaults to {@link DEFAULT_TIMEOUT_MS}.
|
|
*
|
|
* Set to `0` to disable the timeout entirely (not recommended — this is
|
|
* the safety net against shadowbans).
|
|
*/
|
|
timeoutMs?: number;
|
|
/**
|
|
* Treat HTTP status `404` (and optionally others) as "this endpoint doesn't
|
|
* support this path" rather than "everything is broken". The endpoint stays
|
|
* healthy, but we still try the next one in the list. Used for the
|
|
* mempool.space-specific `/v1/prices` endpoint which is absent on backends
|
|
* like Blockstream Esplora.
|
|
*
|
|
* Defaults to `[]` — every non-retryable error response is returned to the
|
|
* caller as-is.
|
|
*/
|
|
skipStatuses?: number[];
|
|
}
|
|
|
|
/** Error thrown when every endpoint in the list is unreachable or cooled down. */
|
|
class EsploraAllEndpointsFailedError extends Error {
|
|
constructor(
|
|
/** Original URLs that were attempted. */
|
|
public readonly urls: string[],
|
|
/** Per-URL failure reasons in attempt order. */
|
|
public readonly causes: Array<{ url: string; reason: string }>,
|
|
) {
|
|
const summary = causes.map((c) => `${c.url} → ${c.reason}`).join('; ');
|
|
super(`All Esplora endpoints failed: ${summary || '(none available)'}`);
|
|
this.name = 'EsploraAllEndpointsFailedError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a single AbortSignal that fires when either the caller's signal
|
|
* aborts OR the per-attempt timeout elapses. Returns the merged signal plus
|
|
* a cleanup function to clear the timer once the attempt finishes. Uses
|
|
* `AbortSignal.any` when available (modern Chrome/Firefox/Safari and
|
|
* recent WKWebView/Android WebView via Capacitor); falls back to manual
|
|
* listener wiring on older runtimes.
|
|
*/
|
|
function buildAttemptSignal(
|
|
callerSignal: AbortSignal | undefined,
|
|
timeoutMs: number,
|
|
): { signal: AbortSignal; cleanup: () => void; timedOut: () => boolean } {
|
|
const timeoutController = new AbortController();
|
|
let didTimeout = false;
|
|
const timer = timeoutMs > 0
|
|
? setTimeout(() => {
|
|
didTimeout = true;
|
|
timeoutController.abort();
|
|
}, timeoutMs)
|
|
: undefined;
|
|
|
|
// Compose timeout + caller signal. AbortSignal.any is the clean path.
|
|
const signals: AbortSignal[] = [timeoutController.signal];
|
|
if (callerSignal) signals.push(callerSignal);
|
|
|
|
let signal: AbortSignal;
|
|
let removeListener: (() => void) | undefined;
|
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
|
|
signal = AbortSignal.any(signals);
|
|
} else if (callerSignal) {
|
|
// Manual composition: forward caller's abort onto the timeout controller
|
|
// so the timeout's signal is the single source of truth.
|
|
if (callerSignal.aborted) {
|
|
timeoutController.abort();
|
|
} else {
|
|
const onAbort = () => timeoutController.abort();
|
|
callerSignal.addEventListener('abort', onAbort, { once: true });
|
|
removeListener = () => callerSignal.removeEventListener('abort', onAbort);
|
|
}
|
|
signal = timeoutController.signal;
|
|
} else {
|
|
signal = timeoutController.signal;
|
|
}
|
|
|
|
return {
|
|
signal,
|
|
cleanup: () => {
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
removeListener?.();
|
|
},
|
|
timedOut: () => didTimeout,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch an Esplora REST path with ordered failover across `baseUrls`.
|
|
*
|
|
* Iterates the URL list in order, skipping any endpoint currently in
|
|
* cool-down. The first URL that returns a non-retryable response wins —
|
|
* callers handle 2xx and "expected" 4xx (400, 404 for genuine not-found,
|
|
* etc.) themselves.
|
|
*
|
|
* Each attempt is bounded by a timeout (default 15s) and the caller's
|
|
* abort signal. Timeouts count as endpoint failures (cool-down + try next);
|
|
* caller aborts propagate immediately.
|
|
*
|
|
* @param baseUrls Ordered list of Esplora REST roots, e.g.
|
|
* `['https://mempool.space/api', 'https://blockstream.info/api']`.
|
|
* Each should be a full URL with no trailing slash, but a
|
|
* trailing slash is tolerated.
|
|
* @param path Path beginning with `/`, e.g. `/address/bc1.../utxo`.
|
|
* @param options Standard `fetch` options plus `signal`, `timeoutMs`, and
|
|
* `skipStatuses` for soft failover on endpoint-capability
|
|
* mismatches.
|
|
*/
|
|
export async function esploraFetch(
|
|
baseUrls: string[],
|
|
path: string,
|
|
options: EsploraFetchOptions = {},
|
|
): Promise<Response> {
|
|
if (baseUrls.length === 0) {
|
|
throw new EsploraAllEndpointsFailedError([], []);
|
|
}
|
|
|
|
const {
|
|
skipStatuses = [],
|
|
signal: callerSignal,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
...fetchInit
|
|
} = options;
|
|
|
|
// Caller already gave up before we even started. Honour that immediately.
|
|
if (callerSignal?.aborted) {
|
|
throw callerSignal.reason instanceof Error
|
|
? callerSignal.reason
|
|
: new DOMException('Aborted', 'AbortError');
|
|
}
|
|
|
|
const skip = new Set(skipStatuses);
|
|
const causes: Array<{ url: string; reason: string }> = [];
|
|
const now = Date.now();
|
|
|
|
// Build the attempt order: available endpoints first, then cooled-down ones
|
|
// as a last-resort fallback. This way, when *every* endpoint is cooling
|
|
// down we still try them rather than dying instantly.
|
|
const normalized = baseUrls.map(normalize);
|
|
const available = normalized.filter((u) => isAvailable(u, now));
|
|
const cooling = normalized.filter((u) => !isAvailable(u, now));
|
|
const attemptOrder = available.length > 0 ? [...available, ...cooling] : cooling;
|
|
|
|
for (const baseUrl of attemptOrder) {
|
|
const fullUrl = `${baseUrl}${path}`;
|
|
const attempt = buildAttemptSignal(callerSignal, timeoutMs);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(fullUrl, { ...fetchInit, signal: attempt.signal });
|
|
} catch (err) {
|
|
attempt.cleanup();
|
|
|
|
// Caller aborted: propagate. Don't continue to other endpoints, don't
|
|
// penalize this one.
|
|
if (callerSignal?.aborted) {
|
|
throw err;
|
|
}
|
|
|
|
// Per-attempt timeout: treat as a soft failure for *this* endpoint
|
|
// and advance to the next URL. This is the shadowban defence — when
|
|
// mempool.space rate-limits, it sometimes just absorbs the connection
|
|
// and never responds; the timeout converts that hang into a regular
|
|
// failover signal.
|
|
if (attempt.timedOut()) {
|
|
markFailure(baseUrl, Date.now());
|
|
causes.push({ url: baseUrl, reason: `timeout after ${timeoutMs}ms` });
|
|
continue;
|
|
}
|
|
|
|
// Generic network error / DNS failure / CORS error.
|
|
markFailure(baseUrl, Date.now());
|
|
causes.push({ url: baseUrl, reason: err instanceof Error ? err.message : String(err) });
|
|
continue;
|
|
}
|
|
attempt.cleanup();
|
|
|
|
if (response.ok) {
|
|
markSuccess(baseUrl);
|
|
return response;
|
|
}
|
|
|
|
// "Endpoint capability mismatch" (e.g. /v1/prices on Blockstream).
|
|
// The endpoint is fine — it just doesn't speak that path. Try the
|
|
// next URL but don't penalize this one.
|
|
if (skip.has(response.status)) {
|
|
causes.push({ url: baseUrl, reason: `HTTP ${response.status} (skipped)` });
|
|
continue;
|
|
}
|
|
|
|
// 5xx / 429 / 408 → cool down and try the next URL.
|
|
if (RETRYABLE_STATUS.has(response.status)) {
|
|
markFailure(baseUrl, Date.now());
|
|
causes.push({ url: baseUrl, reason: `HTTP ${response.status}` });
|
|
continue;
|
|
}
|
|
|
|
// Non-retryable 4xx (400, 404 for genuine "not found", etc.).
|
|
// Return as-is — this is a real answer that won't change by retrying.
|
|
markSuccess(baseUrl);
|
|
return response;
|
|
}
|
|
|
|
throw new EsploraAllEndpointsFailedError(baseUrls, causes);
|
|
}
|
|
|