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..3728b65 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'; @@ -284,14 +285,26 @@ async function resolveTarget( return resolveCoords(parsed.coords); } if (parsed.kind === 'ff-url') { - if (parsed.urlKind === 'show') { + // Shows and series both resolve through the source-resolver's Feral File + // site module, which enumerates exactly the tokens the page describes + // (series slug → /api/series → /api/artworks?seriesID). The old series + // path resolved ONE token via ff-marketplace and then asked Raster, which + // expands to the token's parent artwork — so a single-edition series page + // (e.g. a Display Edition) surprisingly built the sibling collection's + // full token list. A series URL means the user pointed at a specific + // series; build exactly that. Artwork URLs keep the coords→Raster path: + // a single-token page carries no series intent to preserve. + if (parsed.urlKind === 'show' || parsed.urlKind === 'series') { + const label = parsed.urlKind === 'show' ? 'show' : 'series'; const target = await resolveTokenListInput( input, resolverLimitFromOption(limitOption), - `Feral File show ${parsed.identifier}` + `Feral File ${label} ${parsed.identifier}` ); if (target === null) { - throw new Error(`Feral File: no supported tokens found for show "${parsed.identifier}".`); + throw new Error( + `Feral File: no supported tokens found for ${label} "${parsed.identifier}".` + ); } return target; } @@ -410,9 +423,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..81e27c8 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', () => { @@ -286,11 +309,12 @@ describe('find command — resolver-backed token-list flow', () => { const superRareCollectionUrl = 'https://superrare.com/collection/0x1234567890123456789012345678901234567890'; const feralFileShowUrl = 'https://feralfile.com/exhibitions/shows/mock-show'; + const feralFileSeriesUrl = 'https://feralfile.com/exhibitions/series/mock-series'; const dir = mkdtempSync(join(tmpdir(), 'ff-find-token-list-')); const originalCwd = process.cwd(); const resolverCalls: Array<{ input: string; options: { limit: number } }> = []; const promptQuestions: string[] = []; - const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's']; + const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's', 'yes', 's']; const indexedBatches: unknown[][] = []; const stdoutWrites: string[] = []; const stderrWrites: string[] = []; @@ -321,6 +345,23 @@ describe('find command — resolver-backed token-list flow', () => { reason: 'mock resolver miss', }; } + // Series pages resolve to exactly the tokens the page describes — + // one here, mirroring a single-edition series (the case the old + // ff-marketplace→Raster path expanded to a sibling collection). + if (input === feralFileSeriesUrl) { + return { + kind: 'tokens', + title: 'Mock Feral File Series', + coords: [ + { + chain: 'ethereum', + contract: '0x1234567890123456789012345678901234567890', + tokenId: '21', + }, + ], + hasMore: false, + }; + } const isSuperRareCollection = input === superRareCollectionUrl; const isFeralFileShow = input === feralFileShowUrl; return { @@ -537,6 +578,56 @@ describe('find command — resolver-backed token-list flow', () => { stderrWrites.join(''), /Feral File: no supported tokens found for show "mock-show"/ ); + + // Series URLs route through the same resolver token-list path as shows + // (never ff-marketplace→Raster, which expanded a single-edition series + // to its parent artwork's full token list). The mock Raster server + // seeing zero traffic is asserted implicitly: a Raster call would hit + // this test's GraphQL handler and fail the deepEqual on indexedBatches. + resolverCalls.length = 0; + promptQuestions.length = 0; + indexedBatches.length = 0; + stdoutWrites.length = 0; + stderrWrites.length = 0; + resolverMissInputs.delete(feralFileShowUrl); + + await findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], { + from: 'node', + }); + + assert.deepEqual(resolverCalls, [ + { + input: feralFileSeriesUrl, + options: { limit: 1 }, + }, + ]); + assert.deepEqual(indexedBatches, [ + [ + { + chain: 'ethereum', + contractAddress: '0x1234567890123456789012345678901234567890', + tokenId: '21', + }, + ], + ]); + assert.match(stdoutWrites.join(''), /Mock Feral File Series/); + + resolverCalls.length = 0; + stderrWrites.length = 0; + resolverMissInputs.add(feralFileSeriesUrl); + + await assert.rejects( + () => + findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], { + from: 'node', + }), + /process\.exit\(1\)/ + ); + + assert.match( + stderrWrites.join(''), + /Feral File: no supported tokens found for series "mock-series"/ + ); } finally { process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; 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; + } + ); + }); +});