diff --git a/__tests__/run.test.ts b/__tests__/run.test.ts index a90cec2..f8a0054 100644 --- a/__tests__/run.test.ts +++ b/__tests__/run.test.ts @@ -207,6 +207,52 @@ describe("run output paths", () => { }); }); +describe("failed run traceability", () => { + // Every bug report filed through `anyapi report-bug` on 2026-08-26 said "no + // request id was produced": the gateway sends one on X-Anyapi-Request-Id for + // failures too, and this client dropped it, leaving support to reconstruct + // the run from a customer id and a timestamp. + it("surfaces the gateway request id on a failed run", async () => { + const fetchImpl: FetchLike = async () => + new Response( + JSON.stringify({ error: "all providers failed", code: "all_providers_failed" }), + { + status: 502, + headers: { + "Content-Type": "application/json", + "X-Anyapi-Request-Id": "0a508adc-c7d7-4734-a43e-fdbb3d3b7b0e", + }, + }, + ); + const client = new AnyApiClient({ + apiKey: "aa_live_test", + fetchImpl, + restBaseUrl: "https://example.test/v1", + }); + + await expect(client.run("facebook.search_companies", { query: "acme" })) + .rejects.toThrow( + "all providers failed (request 0a508adc-c7d7-4734-a43e-fdbb3d3b7b0e)", + ); + }); + + it("leaves the message alone when the response carries no request id", async () => { + const fetchImpl: FetchLike = async () => + new Response(JSON.stringify({ error: "Missing or invalid API key." }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + const client = new AnyApiClient({ + apiKey: "aa_live_test", + fetchImpl, + restBaseUrl: "https://example.test/v1", + }); + + await expect(client.run("reddit.search", { query: "anyapi" })) + .rejects.toThrow(/^Missing or invalid API key\.$/); + }); +}); + describe("402 handling", () => { it("detects trial cap errors and relays the server upgrade guidance", async () => { const fetchImpl: FetchLike = async () => diff --git a/package.json b/package.json index d9c4980..246698a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anyapi-cli", - "version": "0.8.0", + "version": "0.8.1", "description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.", "type": "module", "bin": { diff --git a/skills/anyapi-run/SKILL.md b/skills/anyapi-run/SKILL.md index 7f67e51..2aef414 100644 --- a/skills/anyapi-run/SKILL.md +++ b/skills/anyapi-run/SKILL.md @@ -112,9 +112,12 @@ anyapi report-bug "reels_search returned no items for a query with results" \ ``` `--request-id` is the single most useful thing to attach: it reaches the stored -run and its upstream error body, so you never need to paste the payload. Use -`anyapi feedback` instead for what is not a defect, such as an API you could not -find in the catalog or a field missing from a result. +run and its upstream error body, so you never need to paste the payload. On a +successful run it is the `requestId` in the result; on a FAILED run the CLI +prints it in the error itself, as `all providers failed (request )`. A +report filed without it costs support a reconstruction from your customer id +and a timestamp. Use `anyapi feedback` instead for what is not a defect, such as +an API you could not find in the catalog or a field missing from a result. File it and carry on with the best alternative you have. Do not stop your human's task to ask permission first. diff --git a/src/api.ts b/src/api.ts index 316baa2..e1ad269 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import { CATALOG_URL, REST_BASE_URL, SIGNUP_URL } from './constants.js'; +import { CATALOG_URL, REQUEST_ID_HEADER, REST_BASE_URL, SIGNUP_URL } from './constants.js'; import { readCatalogResponse, readDiscoveryApi, readSearchResponse } from './discovery.js'; import { ApiError, CliError } from './errors.js'; import type { @@ -233,7 +233,12 @@ export class AnyApiClient { const response = await this.fetchImpl(input, init); const body = await parseBody(response); if (!response.ok) { - throw new ApiError(errorMessage(body, response.status), response.status, body); + throw new ApiError( + errorMessage(body, response.status), + response.status, + body, + response.headers.get(REQUEST_ID_HEADER) ?? '', + ); } return body as T; } diff --git a/src/constants.ts b/src/constants.ts index f66807b..450055b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,12 @@ export const OAUTH_DEVICE_AUTHORIZATION_URL = `${API_BASE_URL}/oauth/device_auth export const OAUTH_TOKEN_URL = `${API_BASE_URL}/oauth/token`; export const OAUTH_REGISTER_URL = `${API_BASE_URL}/oauth/register`; export const OAUTH_SCOPE = 'run balance:read'; +/** + * The response header every /v1/run answer carries, success or failure. It is + * the only handle that reaches the stored request and its retained upstream + * body, so a failed run must surface it rather than print the message alone. + */ +export const REQUEST_ID_HEADER = 'X-Anyapi-Request-Id'; export const API_KEY_ENV = 'ANYAPI_API_KEY'; export const CONFIG_DIR_NAME = '.anyapi'; export const CONFIG_FILE_NAME = 'config.json'; diff --git a/src/errors.ts b/src/errors.ts index b64f84e..e645150 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -11,11 +11,20 @@ export class CliError extends Error { export class ApiError extends Error { readonly status: number; readonly body: unknown; + /** + * The failed run's AnyAPI request id, when the gateway reported one. Every + * /v1/run response carries it on the X-Anyapi-Request-Id header, including + * failures, and it is the handle support needs to read the stored request, + * its attempts, and the retained upstream body. Empty on routes that execute + * no run. + */ + readonly requestId: string; - constructor(message: string, status: number, body: unknown) { - super(message); + constructor(message: string, status: number, body: unknown, requestId = '') { + super(requestId === '' ? message : `${message} (request ${requestId})`); this.name = 'ApiError'; this.status = status; this.body = body; + this.requestId = requestId; } }