From eae4241ad8c8ecfea0bf77f613973aae1131785b Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 20:08:17 +0800 Subject: [PATCH 1/3] fix(find): survive slow/broken network paths to Raster (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers, one root cause. Node's happy-eyeballs gives each address-family connect attempt 250 ms — shorter than one round trip to api.raster.art from Asia (~250 ms RTT). On networks with broken IPv6 the IPv4 attempt dies mid-handshake, the IPv6 fallback has no route, and find fails or stalls with no output. - entry: raise the auto-select-family attempt timeout to 2.5 s so a transcontinental IPv4 connect is not cut off mid-handshake - raster-client: 20 s AbortSignal on every query — a stalled request becomes a fast error, never a silent hang; network failures rethrow as RasterUnreachableError naming the endpoint and the undici cause (bare 'fetch failed' says neither) - find: Raster unreachable at the coords step degrades to a one-item playlist with a warning instead of dying — Raster enriches a find, it is not required to build something playable Repro from #97 (FF series URL that hung at 90 s and 150 s) now completes: 35 items, or a fast actionable error when the network is truly down. Suite: 447/447. --- index.ts | 9 +++++++ src/commands/find.ts | 18 ++++++++++++- src/utilities/raster-client.ts | 43 +++++++++++++++++++++++++++---- tests/find-resolvers.test.ts | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) 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..51787b7 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,11 +140,22 @@ 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(); throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`); diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 63bc1cc..b33ff76 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -696,3 +696,49 @@ 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); + }); +}); From 35b26f1362681635cf1d6627666468c411b9a3de Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 21:06:45 +0800 Subject: [PATCH 2/3] fix(review): extend the network-error boundary through Raster body reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviewers caught it: rasterQuery only converted failures from fetch() itself. A body that stalls or terminates after headers rejects in response.text()/response.json(), outside the boundary — so resolveCoords couldn't recognize RasterUnreachableError and never performed its promised single-token fallback. Body consumption now sits inside the boundary: a rejected read on an ok response throws RasterUnreachableError (a 200 whose body can't be read is unusable either way; falling back is right even for server-side garbage). Intentional API-level errors keep their types: an HTTP error stays a status error even when its body is unreadable (best-effort text, never masks the status), and GraphQL errors are unchanged. Tests: mid-stream termination → typed error; 502 with unreadable body → status error, not unreachable; end-to-end find run against a mock server that destroys the body → warns and degrades to a one-item playlist. 450/450. --- src/utilities/raster-client.ts | 23 ++++++++++++++++-- tests/find-command.test.ts | 23 ++++++++++++++++++ tests/find-resolvers.test.ts | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/utilities/raster-client.ts b/src/utilities/raster-client.ts index 51787b7..7f33967 100644 --- a/src/utilities/raster-client.ts +++ b/src/utilities/raster-client.ts @@ -157,10 +157,29 @@ async function rasterQuery(query: string, variables: Record) 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 b33ff76..ead7def 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -742,3 +742,47 @@ describe('rasterQuery network resilience (#97)', () => { 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; + } + ); + }); +}); From 698727ec7853b4bfe8ad59d56b485522a7534bd6 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 20:33:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(find):=20FF=20series=20URLs=20reso?= =?UTF-8?q?lve=20through=20the=20resolver,=20not=20ff-marketplace=E2=86=92?= =?UTF-8?q?Raster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Feral File series URL resolved ONE token via ff-marketplace, then asked Raster, which maps the token to its parent artwork — so a single-edition series page (e.g. 36 Points Display Edition) built the sibling collection's 35 tokens instead of the 1 the page describes. Flagged as out-of-scope in #98; this is that fix. Series URLs now route through source-resolver's Feral File site module (series slug → /api/series → /api/artworks?seriesID), exactly like shows already did — one FF resolution path for page-shaped URLs, and find builds precisely the tokens the page describes. Artwork URLs keep the coords→Raster path: a single-token page carries no series intent to preserve. Verified live: the Display Edition series URL now builds 1 item with correct provenance (was 35), in seconds (was minutes of indexer warming for tokens the user never asked about). --- src/commands/find.ts | 18 ++++++++-- tests/find-command.test.ts | 70 +++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/commands/find.ts b/src/commands/find.ts index 71375fb..3728b65 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -285,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; } diff --git a/tests/find-command.test.ts b/tests/find-command.test.ts index f0f68af..81e27c8 100644 --- a/tests/find-command.test.ts +++ b/tests/find-command.test.ts @@ -309,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[] = []; @@ -344,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 { @@ -560,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;