From 39c5f447b45d4f30866273a6753119c715a8c335 Mon Sep 17 00:00:00 2001 From: Tom Kirkpatrick Date: Sat, 20 Jan 2024 15:50:11 +0000 Subject: [PATCH] Add support for fallback APIs --- README.md | 4 ++ config/custom-environment-variables.json | 4 +- config/default.json | 4 +- src/custom.d.ts | 4 +- src/server.tsx | 70 ++++++++++++++++++------ 5 files changed, 67 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 00274df..55b0e70 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ Here are the available configuration options: - `server.port`: The port on which the server runs. Default is `3000` - `server.baseUrl`: The base url port on which the server is accessible. Default is `http://localhost:3000` - `esplora.baseUrl`: The base URL of the Esplora API instance to connect to. Default is `https://blockstream.info` +- `esplora.fallbacekBaseUrl`: The base URL of the Esplora API instance to fallback to if the primary instance is unavailable. - `mempool.baseUrl`: The base URL of the Mempool instance to connect to. Default is `https://mempool.space` +- `mempool.fallbacekBaseUrl`: The base URL of the Mempool instance to fallback to if the primary instance is unavailable. - `mempool.depth`: The number of blocks to use for mempool-based fee estimates. Default is `6`. Valid options are `1`, `3`, and `6` - `settings.feeMultiplier`: The multiplier to apply to the fee estimates. Default is `1` (a conservative approach to ensure that the fee estimates are always slightly higher than the raw estimates) - `cache.stdTTL`: The standard time to live in seconds for every generated cache element. Default is `15` @@ -80,7 +82,9 @@ In addition to configuring the application through the config files, you can als - `PORT`: Overrides `server.port` - `BASE_URL`: Overrides `server.baseUrl` - `ESPLORA_BASE_URL`: Overrides `esplora.baseUrl` +- `ESPLORA_FALLBACK_BASE_URL`: Overrides `esplora.fallbackBaseUrl` - `MEMPOOL_BASE_URL`: Overrides `mempool.baseUrl` +- `MEMPOOL_FALLBACK_BASE_URL`: Overrides `mempool.fallbackBaseUrl` - `MEMPOOL_DEPTH`: Overrides `mempool.depth` - `FEE_MULTIPLIER`: Overrides `settings.feeMultiplier` - `CACHE_STDTTL`: Overrides `cache.stdTTL` diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index c41feee..bfd14e1 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -8,10 +8,12 @@ }, "mempool": { "baseUrl": "MEMPOOL_BASE_URL", + "fallbackBaseUrl": "MEMPOOL_FALLBACK_BASE_URL", "depth": "MEMPOOL_DEPTH" }, "esplora": { - "baseUrl": "ESPLORA_BASE_URL" + "baseUrl": "ESPLORA_BASE_URL", + "fallbackBaseUrl": "ESPLORA_FALLBACK_BASE_URL" }, "cache": { "stdTTL": "CACHE_STD_TTL", diff --git a/config/default.json b/config/default.json index bae939c..01aa45f 100644 --- a/config/default.json +++ b/config/default.json @@ -8,10 +8,12 @@ }, "mempool": { "baseUrl": "https://mempool.space", + "fallbackBaseUrl": null, "depth": 6 }, "esplora": { - "baseUrl": "https://blockstream.info" + "baseUrl": "https://blockstream.info", + "fallbackBaseUrl": null }, "cache": { "stdTTL": 15, diff --git a/src/custom.d.ts b/src/custom.d.ts index af1261b..5228cbb 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -28,4 +28,6 @@ interface SiteData { title: string, subtitle: string, children?: any -} \ No newline at end of file +} + +type ExpectedResponseType = 'json' | 'text'; \ No newline at end of file diff --git a/src/server.tsx b/src/server.tsx index c9c3c4e..d203967 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -11,7 +11,9 @@ import NodeCache from 'node-cache'; const port = config.get('server.port'); const baseUrl = config.get('server.baseUrl'); const esploraBaseUrl = config.get('esplora.baseUrl'); +const esploraFallbackBaseUrl = config.get('esplora.fallbackBaseUrl'); const mempoolBaseUrl = config.get('mempool.baseUrl'); +const mempoolFallbackBaseUrl = config.get('mempool.fallbackBaseUrl'); const mempoolDepth = config.get('mempool.depth'); const feeMultiplier = config.get('settings.feeMultiplier'); const stdTTL = config.get('cache.stdTTL'); @@ -25,7 +27,9 @@ console.info('---'); console.info(`Using port: ${port}`); console.info(`Using base URL: ${baseUrl}`); console.info(`Using Esplora base URL: ${esploraBaseUrl}`); +console.info(`Using Esplora fallback base URL: ${esploraFallbackBaseUrl}`); console.info(`Using Mempool base URL: ${mempoolBaseUrl}`); +console.info(`Using Mempool fallback base URL: ${mempoolFallbackBaseUrl}`); console.info(`Using Mempool estimation depth: ${mempoolDepth}`); console.info(`Using fee multiplier: ${feeMultiplier}`); console.info(`Using cache stdTTL: ${stdTTL}`); @@ -38,6 +42,11 @@ const ESPLORA_TIP_HASH_URL = `${esploraBaseUrl}/api/blocks/tip/hash`; const MEMPOOL_FEES_URL = `${mempoolBaseUrl}/api/v1/fees/recommended`; const ESPLORA_FEE_ESTIMATES_URL = `${esploraBaseUrl}/api/fee-estimates`; +const MEMPOOL_TIP_HASH_URL_FALLBACK = `${mempoolFallbackBaseUrl}/api/blocks/tip/hash`; +const ESPLORA_TIP_HASH_URL_FALLBACK = `${esploraFallbackBaseUrl}/api/blocks/tip/hash`; +const MEMPOOL_FEES_URL_FALLBACK = `${mempoolFallbackBaseUrl}/api/v1/fees/recommended`; +const ESPLORA_FEE_ESTIMATES_URL_FALLBACK = `${esploraFallbackBaseUrl}/api/fee-estimates`; + // Initialize the cache. const cache = new NodeCache({ stdTTL: stdTTL, checkperiod: checkperiod }); const CACHE_KEY = 'estimates'; @@ -54,28 +63,57 @@ async function fetchWithTimeout(url: string, timeout: number = TIMEOUT): Promise return Promise.race([fetchPromise, timeoutPromise]) as Promise; } + /** * Fetches data from the given URL and returns the response as a string or object. */ -async function fetchAndHandle(url: string): Promise { +async function fetchAndProcess(url: string, expectedResponseType: ExpectedResponseType): Promise { + const response = await fetchWithTimeout(url, TIMEOUT); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + console.debug(`Successfully fetched data from ${url}`); + + const contentType = response.headers.get("content-type"); + if (expectedResponseType === 'json' && contentType?.includes("application/json")) { + return response.json(); + } else if (expectedResponseType === 'text' && contentType?.includes("text/plain")) { + const text = await response.text(); + const trimmedText = text.trim(); + if (trimmedText.includes('\n') || text !== trimmedText) { + throw new Error('Response is not a single text string with no whitespace or newlines'); + } + return trimmedText; + } else { + throw new Error(`Unexpected response type. Expected ${expectedResponseType}, but received ${contentType}`); + } +} + +/** + * Fetches data from the given URL and returns the response as a string or object. + */ +async function fetchAndHandle(url: string, expectedResponseType: ExpectedResponseType, fallbackUrl?: string): Promise { try { - const response = await fetchWithTimeout(url, TIMEOUT); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const timeout = new Promise((resolve) => setTimeout(resolve, TIMEOUT, 'timeout')); + const fetchPromise = fetchAndProcess(url, expectedResponseType); + const result = await Promise.race([fetchPromise, timeout]) as Promise | string; + + if (result === 'timeout' || result instanceof Error) { + throw new Error('Fetch or timeout error'); } - console.debug(`Successfully fetched data from ${url}`); - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return await response.json(); + + return result; + } catch (error) { + console.info('Trying fallback URL', fallbackUrl); + if (fallbackUrl) { + return fetchAndProcess(fallbackUrl, expectedResponseType); } else { - return await response.text(); + throw new Error(`Fetch request to ${url} failed and no fallback URL was provided.`); } - } catch (error) { - console.error(`Error fetching from ${url}:`, error); - return null; } } + // Initialize the Express app. const app = new Hono(); console.info(`Fee Estimates available at ${baseUrl}/v1/fee-estimates`); @@ -103,10 +141,10 @@ app.use('/static/*', serveStatic({ root: './' })) */ async function fetchData() { const tasks = [ - fetchAndHandle(MEMPOOL_TIP_HASH_URL), - fetchAndHandle(ESPLORA_TIP_HASH_URL), - fetchAndHandle(MEMPOOL_FEES_URL), - fetchAndHandle(ESPLORA_FEE_ESTIMATES_URL) + fetchAndHandle(MEMPOOL_TIP_HASH_URL, 'text', MEMPOOL_TIP_HASH_URL_FALLBACK), + fetchAndHandle(ESPLORA_TIP_HASH_URL, 'text', ESPLORA_TIP_HASH_URL_FALLBACK), + fetchAndHandle(MEMPOOL_FEES_URL, 'json', MEMPOOL_FEES_URL_FALLBACK), + fetchAndHandle(ESPLORA_FEE_ESTIMATES_URL, 'json', ESPLORA_FEE_ESTIMATES_URL_FALLBACK) ]; return await Promise.allSettled(tasks);