diff --git a/index.ts b/index.ts index 383fb05..24c7dcc 100644 --- a/index.ts +++ b/index.ts @@ -9,7 +9,16 @@ process.on('warning', (warning) => { }); import 'dotenv/config'; +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'net'; import { Command } from 'commander'; + +// Node's happy-eyeballs gives each address-family connect attempt 250 ms by +// default. That is shorter than a single round trip to a far-away API host +// (e.g. api.raster.art from Asia is ~250 ms RTT), so on networks with broken +// IPv6 the IPv4 attempt gets cut off mid-handshake, the IPv6 fallback has no +// route, and fetch fails or stalls intermittently (#97). 2.5 s per attempt is +// still fast-failing but no longer races the speed of light. +setDefaultAutoSelectFamilyAttemptTimeout(2500); import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { setupCommand } from './src/commands/setup'; diff --git a/src/commands/find.ts b/src/commands/find.ts index fbcad74..71375fb 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -20,6 +20,7 @@ import { listArtistArtworks, resolveAddressToArtist, formatSummaryLine, + RasterUnreachableError, } from '../utilities/raster-client'; import type { RasterArtworkSummary, RasterArtworkRow } from '../utilities/raster-client'; import { castPlaylist } from '../utilities/playlist-cast'; @@ -410,9 +411,24 @@ async function resolveTokenListInput( /** * Resolve on-chain coords to a target. If Raster doesn't index this token, * fall back to single-token mode so we still build something playable. + * + * Raster being unreachable gets the same fallback: it only enriches the find + * (series enumeration, artist labels) — the coords in hand are enough to + * build a playable single-token playlist, and a network blip on one optional + * dependency must not kill the whole command (#97). */ async function resolveCoords(coords: TokenCoords): Promise { - const summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId); + let summary: RasterArtworkSummary | null; + try { + summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId); + } catch (error) { + if (!(error instanceof RasterUnreachableError)) { + throw error; + } + console.log(chalk.yellow(` ${error.message}`)); + console.log(chalk.dim(' Continuing without Raster — building a one-item playlist.')); + return { kind: 'single', coords }; + } if (summary === null) { return { kind: 'single', coords }; } diff --git a/src/utilities/raster-client.ts b/src/utilities/raster-client.ts index 5b5cbd2..7f33967 100644 --- a/src/utilities/raster-client.ts +++ b/src/utilities/raster-client.ts @@ -28,6 +28,15 @@ import { USER_AGENT } from './user-agent'; const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql'; +/** + * Hard ceiling on a single Raster round trip. Without it, a stalled TCP + * connect or unanswered request leaves `find` hanging with no output (#97) — + * the failure mode agents can least diagnose. 20 s is generous for a + * transcontinental GraphQL query and still turns "silent hang" into a fast, + * actionable error. + */ +const RASTER_REQUEST_TIMEOUT_MS = 20_000; + // CAIP-2 chain IDs for the chains the FF indexer supports. // Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU), // not the human-readable "tezos:mainnet" — Raster returns the hash form on @@ -44,6 +53,19 @@ const INDEXER_CHAIN_TO_CAIP: Record = { export type IndexerChain = 'ethereum' | 'tezos'; +/** + * Raster could not be reached at the network level (connect failure, broken + * IPv6 path, timeout). Distinct from API-level errors so callers that hold + * usable token coordinates can degrade gracefully instead of dying — Raster + * only enriches a find, it is not required to build a playable playlist. + */ +export class RasterUnreachableError extends Error { + constructor(message: string) { + super(message); + this.name = 'RasterUnreachableError'; + } +} + export interface RasterArtist { id: string; name: string; @@ -118,16 +140,46 @@ async function rasterQuery(query: string, variables: Record) if (apiKey) { headers['x-api-key'] = apiKey; } - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify({ query, variables }), - }); + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(RASTER_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // Undici's "fetch failed" carries no endpoint and no cause in its + // message — name the host and the real failure so the error is + // actionable, and let callers detect network failure by type. + const cause = (error as { cause?: { message?: string } }).cause; + const detail = cause?.message ?? (error as Error).message; + throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): ${detail}`); + } if (!response.ok) { - const body = await response.text(); + // Body text is best-effort context; the HTTP status alone is the error. + // A body read that rejects here must not mask the status — and an HTTP + // error is an API-level failure, deliberately NOT RasterUnreachableError. + const body = await response.text().catch(() => ''); throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`); } - const payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + // The network-error boundary extends through body consumption: a response + // whose stream stalls past the headers or terminates mid-body rejects + // HERE, not in fetch() above. Those must still surface as + // RasterUnreachableError or resolveCoords cannot perform its promised + // single-token fallback. (A 200 whose body cannot be read or parsed is + // unusable either way — falling back is right even for server-side + // garbage.) + let payload: { data?: T; errors?: Array<{ message: string }> }; + try { + payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + } catch (error) { + const cause = (error as { cause?: { message?: string } }).cause; + const detail = cause?.message ?? (error as Error).message; + throw new RasterUnreachableError( + `Raster API unreachable (${endpoint}): response body failed: ${detail}` + ); + } if (payload.errors && payload.errors.length > 0) { throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`); } diff --git a/tests/find-command.test.ts b/tests/find-command.test.ts index 127b853..f0f68af 100644 --- a/tests/find-command.test.ts +++ b/tests/find-command.test.ts @@ -47,6 +47,13 @@ before(async () => { variables: Record; }; res.setHeader('Content-Type', 'application/json'); + if (destroyResponseBody) { + // Headers out, body terminated mid-stream: fetch() resolves, the + // body read rejects — the #98-review failure mode. + res.write('{"data": {'); + res.destroy(); + return; + } res.end(JSON.stringify(handler(query, variables ?? {}))); }); }); @@ -64,8 +71,11 @@ after(() => { server.close(); }); +let destroyResponseBody = false; + beforeEach(() => { handler = () => ({ errors: [{ message: 'no handler installed for this test' }] }); + destroyResponseBody = false; }); interface RunResult { @@ -278,6 +288,19 @@ describe('find command — Raster series flow (mock GraphQL server)', () => { assert.match(result.stdout, /Build playlist with 1 token\?/); assert.match(result.stdout, /Cancelled\./); }); + + test('Raster body terminating mid-stream → warns and degrades to one-item playlist (#98 review)', async () => { + // fetch() succeeds (headers arrive), the body read rejects. The typed + // RasterUnreachableError must survive to resolveCoords so the find flow + // degrades instead of dying — the whole point of the boundary fix. + destroyResponseBody = true; + const result = await runFind([`ethereum:${VALID_ETH_CONTRACT}:1`], { stdin: 'n\n' }); + assert.equal(result.code, 0); + assert.match(result.stdout, /Raster API unreachable/); + assert.match(result.stdout, /Continuing without Raster — building a one-item playlist/); + assert.match(result.stdout, /Build playlist with 1 token\?/); + assert.match(result.stdout, /Cancelled\./); + }); }); describe('find command — resolver-backed token-list flow', () => { diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 63bc1cc..ead7def 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -696,3 +696,93 @@ describe('resolveFeralFileToken', () => { ); }); }); + +describe('rasterQuery network resilience (#97)', () => { + test('connect failure → RasterUnreachableError naming the endpoint and cause', async () => { + const mock = (async () => { + // Undici's opaque envelope: message says nothing, cause has the truth. + throw Object.assign(new TypeError('fetch failed'), { + cause: new Error('connect ETIMEDOUT 65.109.41.181:443'), + }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + assert.match(error.message, /api\.raster\.art/); + assert.match(error.message, /ETIMEDOUT/); + return true; + } + ); + }); + + test('abort (timeout) → RasterUnreachableError, not a hang or bare AbortError', async () => { + const mock = (async () => { + throw Object.assign(new DOMException('This operation was aborted', 'AbortError'), {}); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + return true; + } + ); + }); + + test('fetch carries an abort signal so a stalled request cannot hang forever', async () => { + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ data: { tokenByRef: null } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')); + assert.equal(sawSignal, true); + }); +}); + +describe('rasterQuery body-read failures (#98 review)', () => { + test('body that terminates mid-stream → RasterUnreachableError (fallback stays reachable)', async () => { + const mock = (async () => { + // Headers arrive fine; the body stream errors — response.json() rejects + // outside fetch()'s own rejection path. + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('terminated')); + }, + }); + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + assert.match(error.message, /response body failed/); + return true; + } + ); + }); + + test('HTTP error with unreadable body keeps the status error, not unreachable', async () => { + const mock = (async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('terminated')); + }, + }); + return new Response(body, { status: 502, statusText: 'Bad Gateway' }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + // API-level failure: NOT the network-unreachable type, and the + // status survives even though the body could not be read. + assert.notEqual(error.name, 'RasterUnreachableError'); + assert.match(error.message, /Raster API 502/); + return true; + } + ); + }); +});