Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 17 additions & 1 deletion src/commands/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ResolvedTarget> {
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 };
}
Expand Down
66 changes: 59 additions & 7 deletions src/utilities/raster-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,6 +53,19 @@ const INDEXER_CHAIN_TO_CAIP: Record<IndexerChain, string> = {

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;
Expand Down Expand Up @@ -118,16 +140,46 @@ async function rasterQuery<T>(query: string, variables: Record<string, unknown>)
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('; ')}`);
}
Expand Down
23 changes: 23 additions & 0 deletions tests/find-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ before(async () => {
variables: Record<string, unknown>;
};
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 ?? {})));
});
});
Expand All @@ -64,8 +71,11 @@ after(() => {
server.close();
});

let destroyResponseBody = false;

beforeEach(() => {
handler = () => ({ errors: [{ message: 'no handler installed for this test' }] });
destroyResponseBody = false;
});

interface RunResult {
Expand Down Expand Up @@ -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', () => {
Expand Down
90 changes: 90 additions & 0 deletions tests/find-resolvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
);
});
});
Loading