|
| 1 | +import { CoreRpcError } from '@stacks/rpc-client'; |
| 2 | +import { isPgConnectionError } from '@stacks/api-toolkit'; |
| 3 | + |
| 4 | +// Low-level socket error codes thrown when a backing node (bitcoind RPC or the Stacks core node |
| 5 | +// RPC) that a faucet depends on is down or unreachable. |
| 6 | +const NODE_CONNECTION_ERROR_CODES = [ |
| 7 | + 'ECONNREFUSED', |
| 8 | + 'ECONNRESET', |
| 9 | + 'ETIMEDOUT', |
| 10 | + 'EHOSTUNREACH', |
| 11 | + 'ENOTFOUND', |
| 12 | + 'EAI_AGAIN', |
| 13 | +]; |
| 14 | + |
| 15 | +/** |
| 16 | + * Detects whether an error was caused by an unreachable backing node. Node's socket errors expose a |
| 17 | + * `code`, but some HTTP clients (e.g. undici/`fetch`) nest the original error under `cause`, so we |
| 18 | + * check both, plus the message text as a last resort. |
| 19 | + */ |
| 20 | +export function isNodeConnectionError(error: unknown): boolean { |
| 21 | + const err = error as (Error & { code?: string; cause?: { code?: string } }) | undefined; |
| 22 | + const code = err?.code ?? err?.cause?.code; |
| 23 | + if (code && NODE_CONNECTION_ERROR_CODES.includes(code)) { |
| 24 | + return true; |
| 25 | + } |
| 26 | + const message = err?.message ?? ''; |
| 27 | + return ( |
| 28 | + NODE_CONNECTION_ERROR_CODES.some(c => message.includes(c)) || /fetch failed/i.test(message) |
| 29 | + ); |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Detects a failure caused by the faucet's own account running out of funds. This is an operational |
| 34 | + * condition (the faucet account needs refilling) rather than a client error. It surfaces as: |
| 35 | + * - `NotEnoughFunds`: the Stacks node rejecting an STX/sBTC faucet transaction, and |
| 36 | + * - `not enough total amount in utxo set`: the BTC faucet having no spendable UTXOs to build a tx |
| 37 | + * (note the funds may be present but not yet spendable, e.g. immature coinbase or unconfirmed). |
| 38 | + */ |
| 39 | +export function isInsufficientFundsError(error: unknown): boolean { |
| 40 | + if (getTxRejectionReason(error) === 'NotEnoughFunds') { |
| 41 | + return true; |
| 42 | + } |
| 43 | + const message = (error as Error | undefined)?.message ?? ''; |
| 44 | + return message.includes('not enough total amount in utxo'); |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * Extracts the Stacks node's mempool rejection reason (e.g. `ConflictingNonceInMempool`) from a |
| 49 | + * failed `/v2/transactions` broadcast. The node responds `400` with a JSON body like |
| 50 | + * `{ error: 'transaction rejected', reason: 'TooMuchChaining', ... }`, which `CoreRpcError` |
| 51 | + * surfaces under `details.error`. |
| 52 | + */ |
| 53 | +export function getTxRejectionReason(error: unknown): string | undefined { |
| 54 | + if (!(error instanceof CoreRpcError)) { |
| 55 | + return undefined; |
| 56 | + } |
| 57 | + const body = (error.details as { error?: { reason?: unknown } } | undefined)?.error; |
| 58 | + return typeof body?.reason === 'string' ? body.reason : undefined; |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * The sanitized outcome of an unhandled faucet error: which status to respond with, the |
| 63 | + * client-facing message, and the reason to record in the server log. Faucet handlers talk to |
| 64 | + * backing nodes whose failures would otherwise surface as a generic `500` leaking internal details |
| 65 | + * (e.g. the node's host/port), so every route's error handler runs its error through here and then |
| 66 | + * renders the result in its own response shape. |
| 67 | + */ |
| 68 | +export interface ClassifiedFaucetError { |
| 69 | + statusCode: number; |
| 70 | + /** Client-facing error message. Never includes details of the backing node. */ |
| 71 | + message: string; |
| 72 | + /** |
| 73 | + * Operator-facing reason, appended to the server log line. Undefined for an unrecognized error, |
| 74 | + * which is logged without a suffix. |
| 75 | + */ |
| 76 | + logReason?: string; |
| 77 | +} |
| 78 | + |
| 79 | +export function classifyFaucetError(error: unknown): ClassifiedFaucetError { |
| 80 | + if (isPgConnectionError(error)) { |
| 81 | + return { |
| 82 | + statusCode: 503, |
| 83 | + message: 'Faucet is temporarily unavailable, please try again later', |
| 84 | + logReason: 'database unavailable', |
| 85 | + }; |
| 86 | + } |
| 87 | + if (isNodeConnectionError(error)) { |
| 88 | + return { |
| 89 | + statusCode: 503, |
| 90 | + message: 'Faucet is temporarily unavailable, please try again later', |
| 91 | + logReason: 'backing node is unreachable', |
| 92 | + }; |
| 93 | + } |
| 94 | + if (isInsufficientFundsError(error)) { |
| 95 | + return { |
| 96 | + statusCode: 503, |
| 97 | + message: 'The faucet is temporarily out of funds, please try again later', |
| 98 | + logReason: 'faucet account is out of funds', |
| 99 | + }; |
| 100 | + } |
| 101 | + return { |
| 102 | + statusCode: 500, |
| 103 | + message: 'Faucet request failed, please try again later', |
| 104 | + }; |
| 105 | +} |
0 commit comments