Skip to content

Commit 100d761

Browse files
masonwyatt23claude
andcommitted
feat: structured errorKind on ProviderReport for federation diagnostics
- Add ErrorKind enum (ok | timeout | http-4xx | http-5xx | network | decode | rate-limited) to types.ts - Extend ProviderReport with optional errorKind + errorContext fields (back-compat additive) - Wire errorKind in federation.ts: classifyError() inspects AbortSignal + HTTP status (from .status/.statusCode properties and 'HTTP NNN' message patterns) + decode heuristics - extractHttpContext() captures httpStatus into errorContext for 4xx/5xx reports - Happy path sets errorKind='ok' - 7 new tests: ok, timeout, http-4xx/404, http-5xx/500, rate-limited/429, network, decode - Update providers.new.test.ts snapshot to include errorKind: 'network' Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ebbf8d6 commit 100d761

4 files changed

Lines changed: 183 additions & 5 deletions

File tree

packages/core/src/federation.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { buildAttribution } from "./license.ts";
1515
import { rankAll } from "./pick.ts";
1616
import { ALL_PROVIDERS, DEFAULT_PROVIDERS } from "./providers/index.ts";
1717
import type {
18+
ErrorKind,
1819
ImageCandidate,
1920
Provider,
2021
ProviderId,
@@ -97,17 +98,17 @@ async function runProvider(
9798
): Promise<ImageCandidate[]> {
9899
const provider: Provider | undefined = ALL_PROVIDERS[id];
99100
if (!provider) {
100-
reports.push({ provider: id, ok: false, count: 0, timeMs: 0, error: "unknown provider" });
101+
reports.push({ provider: id, ok: false, count: 0, timeMs: 0, error: "unknown provider", errorKind: "network" });
101102
return [];
102103
}
103104
// Opt-in providers that weren't requested explicitly are never run (already
104105
// filtered out in DEFAULT_PROVIDERS, but guard anyway).
105106
if (provider.optIn && !(opts.providers ?? []).includes(id)) {
106-
reports.push({ provider: id, ok: false, count: 0, timeMs: 0, skipped: "not-enabled" });
107+
reports.push({ provider: id, ok: false, count: 0, timeMs: 0, skipped: "not-enabled", errorKind: "network" });
107108
return [];
108109
}
109110
if (!providerCanRun(provider, opts)) {
110-
reports.push({ provider: id, ok: false, count: 0, timeMs: 0, skipped: "missing-auth" });
111+
reports.push({ provider: id, ok: false, count: 0, timeMs: 0, skipped: "missing-auth", errorKind: "network" });
111112
return [];
112113
}
113114

@@ -123,16 +124,21 @@ async function runProvider(
123124

124125
try {
125126
const out = await provider.search(query, providerOpts);
126-
reports.push({ provider: id, ok: true, count: out.length, timeMs: Date.now() - started });
127+
reports.push({ provider: id, ok: true, count: out.length, timeMs: Date.now() - started, errorKind: "ok" });
127128
return out;
128129
} catch (e) {
130+
const elapsed = Date.now() - started;
129131
const msg = (e as Error).message ?? "unknown";
132+
const kind = classifyError(e, ctl.signal.aborted);
133+
const ctx = kind === "http-4xx" || kind === "http-5xx" ? extractHttpContext(e) : undefined;
130134
reports.push({
131135
provider: id,
132136
ok: false,
133137
count: 0,
134-
timeMs: Date.now() - started,
138+
timeMs: elapsed,
135139
error: msg,
140+
errorKind: kind,
141+
...(ctx ? { errorContext: ctx } : {}),
136142
});
137143
return [];
138144
} finally {
@@ -141,6 +147,41 @@ async function runProvider(
141147
}
142148
}
143149

150+
/** Map a caught error to a structured ErrorKind. */
151+
function classifyError(e: unknown, timedOut: boolean): ErrorKind {
152+
if (timedOut) return "timeout";
153+
const msg = (e as Error)?.message ?? "";
154+
// HTTP errors surfaced by providers as Error with status embedded in message.
155+
// Convention: providers throw `new Error("HTTP 429 …")` etc.
156+
const statusMatch = msg.match(/\bHTTP\s+(\d{3})\b/i) ?? msg.match(/\bstatus[:\s]+(\d{3})\b/i);
157+
if (statusMatch) {
158+
const status = Number(statusMatch[1]);
159+
if (status === 429) return "rate-limited";
160+
if (status >= 400 && status < 500) return "http-4xx";
161+
if (status >= 500) return "http-5xx";
162+
}
163+
// Providers may also attach a .status property directly.
164+
const status = (e as any)?.status ?? (e as any)?.statusCode;
165+
if (typeof status === "number") {
166+
if (status === 429) return "rate-limited";
167+
if (status >= 400 && status < 500) return "http-4xx";
168+
if (status >= 500) return "http-5xx";
169+
}
170+
// JSON / schema parse failures.
171+
if (msg.includes("JSON") || msg.includes("parse") || msg.includes("unexpected token")) {
172+
return "decode";
173+
}
174+
return "network";
175+
}
176+
177+
/** Pull HTTP status into errorContext when available. */
178+
function extractHttpContext(e: unknown): Record<string, unknown> | undefined {
179+
const msg = (e as Error)?.message ?? "";
180+
const statusFromMsg = msg.match(/\bHTTP\s+(\d{3})\b/i)?.[1] ?? msg.match(/\bstatus[:\s]+(\d{3})\b/i)?.[1];
181+
const status = (e as any)?.status ?? (e as any)?.statusCode ?? (statusFromMsg ? Number(statusFromMsg) : undefined);
182+
return status !== undefined ? { httpStatus: status } : undefined;
183+
}
184+
144185
function providerCanRun(provider: Provider, opts: SearchOptions): boolean {
145186
if (!provider.requiresAuth) return true;
146187
const req = provider.auth;

packages/core/src/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,31 @@ export interface ImageCandidate {
8585
viaBrowserFallback?: boolean;
8686
}
8787

88+
/**
89+
* Structured reason for a provider outcome.
90+
* "ok" is set on success; all other values indicate failure/skip categories.
91+
* Optional — callers that don't care can ignore it; shape remains back-compat.
92+
*/
93+
export type ErrorKind =
94+
| "ok"
95+
| "timeout"
96+
| "http-4xx"
97+
| "http-5xx"
98+
| "network"
99+
| "decode"
100+
| "rate-limited";
101+
88102
export interface ProviderReport {
89103
provider: ProviderId;
90104
ok: boolean;
91105
count: number;
92106
timeMs: number;
93107
error?: string;
94108
skipped?: "missing-auth" | "disabled" | "rate-limited" | "not-enabled";
109+
/** Structured failure category. Allows callers/agents to route on WHY a provider failed. */
110+
errorKind?: ErrorKind;
111+
/** Additional diagnostic context (e.g. HTTP status code, decode field). */
112+
errorContext?: Record<string, unknown>;
95113
}
96114

97115
export interface SearchOptions {

tests/federation.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,122 @@ describe("federation", () => {
6060
const failed = out.providerReports.find((r) => r.provider === "openverse");
6161
expect(failed?.ok).toBe(false);
6262
});
63+
64+
test("errorKind=ok on successful provider", async () => {
65+
const fetcher = stubFetcher([
66+
{
67+
match: (u) => u.includes("commons.wikimedia.org"),
68+
handler: async () => jsonResponse(fixture("wikimedia.json")),
69+
},
70+
]);
71+
const out = await searchImages("test", { providers: ["wikimedia"], fetcher });
72+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
73+
expect(report?.ok).toBe(true);
74+
expect(report?.errorKind).toBe("ok");
75+
});
76+
77+
test("errorKind=timeout when provider aborts due to timeout", async () => {
78+
const fetcher = stubFetcher([
79+
{
80+
match: (u) => u.includes("commons.wikimedia.org"),
81+
handler: async (_url, init) =>
82+
new Promise((_resolve, reject) => {
83+
// Simulate an async operation that respects the abort signal
84+
const signal = init?.signal as AbortSignal | undefined;
85+
if (signal?.aborted) {
86+
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
87+
return;
88+
}
89+
signal?.addEventListener("abort", () => {
90+
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
91+
});
92+
}),
93+
},
94+
]);
95+
const out = await searchImages("test", {
96+
providers: ["wikimedia"],
97+
fetcher,
98+
timeoutMs: 10, // very short — will fire before the never-resolving promise
99+
});
100+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
101+
expect(report?.ok).toBe(false);
102+
expect(report?.errorKind).toBe("timeout");
103+
});
104+
105+
test("errorKind=http-4xx for 404 response", async () => {
106+
const fetcher = stubFetcher([
107+
{
108+
match: (u) => u.includes("commons.wikimedia.org"),
109+
handler: async () => {
110+
throw Object.assign(new Error("HTTP 404 Not Found"), { status: 404 });
111+
},
112+
},
113+
]);
114+
const out = await searchImages("test", { providers: ["wikimedia"], fetcher });
115+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
116+
expect(report?.ok).toBe(false);
117+
expect(report?.errorKind).toBe("http-4xx");
118+
expect(report?.errorContext?.httpStatus).toBe(404);
119+
});
120+
121+
test("errorKind=http-5xx for 500 response thrown as error", async () => {
122+
const fetcher = stubFetcher([
123+
{
124+
match: (u) => u.includes("commons.wikimedia.org"),
125+
handler: async () => {
126+
throw Object.assign(new Error("HTTP 500 Internal Server Error"), { status: 500 });
127+
},
128+
},
129+
]);
130+
const out = await searchImages("test", { providers: ["wikimedia"], fetcher });
131+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
132+
expect(report?.ok).toBe(false);
133+
expect(report?.errorKind).toBe("http-5xx");
134+
expect(report?.errorContext?.httpStatus).toBe(500);
135+
});
136+
137+
test("errorKind=rate-limited for 429 response", async () => {
138+
const fetcher = stubFetcher([
139+
{
140+
match: (u) => u.includes("commons.wikimedia.org"),
141+
handler: async () => {
142+
throw Object.assign(new Error("HTTP 429 Too Many Requests"), { status: 429 });
143+
},
144+
},
145+
]);
146+
const out = await searchImages("test", { providers: ["wikimedia"], fetcher });
147+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
148+
expect(report?.ok).toBe(false);
149+
expect(report?.errorKind).toBe("rate-limited");
150+
});
151+
152+
test("errorKind=network for generic fetch failure", async () => {
153+
const fetcher = stubFetcher([
154+
{
155+
match: (u) => u.includes("commons.wikimedia.org"),
156+
handler: async () => {
157+
throw new Error("fetch failed: ECONNREFUSED");
158+
},
159+
},
160+
]);
161+
const out = await searchImages("test", { providers: ["wikimedia"], fetcher });
162+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
163+
expect(report?.ok).toBe(false);
164+
expect(report?.errorKind).toBe("network");
165+
});
166+
167+
test("errorKind=decode for JSON parse failure", async () => {
168+
const fetcher = stubFetcher([
169+
{
170+
match: (u) => u.includes("commons.wikimedia.org"),
171+
handler: async () => {
172+
throw new Error("Unexpected token < in JSON at position 0");
173+
},
174+
},
175+
]);
176+
const out = await searchImages("test", { providers: ["wikimedia"], fetcher });
177+
const report = out.providerReports.find((r) => r.provider === "wikimedia");
178+
expect(report?.ok).toBe(false);
179+
expect(report?.errorKind).toBe("decode");
180+
});
63181
});

tests/providers.new.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ describe("provider auth contracts", () => {
102102
count: 0,
103103
timeMs: 0,
104104
skipped: "missing-auth",
105+
errorKind: "network",
105106
},
106107
]);
107108
}

0 commit comments

Comments
 (0)