@cliwant/mcp-sam-gov 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
package/src/errors.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Structured error envelope for every tool response.
3
+ *
4
+ * Why this exists
5
+ * ----------------
6
+ * Federal APIs fail in 5 distinct ways: rate-limited (429), down
7
+ * (5xx), schema-drift (200 with unexpected shape), notice-not-found
8
+ * (404), and transient network. Each has a different retry strategy.
9
+ * If we just throw, the LLM sees "Tool error: TypeError: x is
10
+ * undefined" and gives up.
11
+ *
12
+ * Every tool should return either:
13
+ * { ok: true, data: ... }
14
+ * { ok: false, error: { kind, message, retryable, retryAfterSeconds? } }
15
+ *
16
+ * The MCP server layer surfaces this as JSON to the calling agent.
17
+ * The agent can then decide: retry now, retry later, or surface
18
+ * the error to the user with appropriate framing.
19
+ */
20
+
21
+ import { ZodError } from "zod";
22
+
23
+ export type ErrorKind =
24
+ /** HTTP 429. Retry after `retryAfterSeconds`. */
25
+ | "rate_limited"
26
+ /** HTTP 5xx or network error. Likely transient. */
27
+ | "upstream_unavailable"
28
+ /** HTTP 404 / empty results. Don't retry. */
29
+ | "not_found"
30
+ /** Caller passed bad input (e.g. malformed noticeId). Don't retry. */
31
+ | "invalid_input"
32
+ /** API returned 200 but we couldn't parse / shape doesn't match. */
33
+ | "schema_drift"
34
+ /** Anything else. Don't retry. */
35
+ | "unknown";
36
+
37
+ export type ToolError = {
38
+ kind: ErrorKind;
39
+ message: string;
40
+ /** Whether the agent should retry. Pairs with retryAfterSeconds. */
41
+ retryable: boolean;
42
+ /** If rate-limited, advisory wait time. Honors `Retry-After` header. */
43
+ retryAfterSeconds?: number;
44
+ /** Echo upstream HTTP status when available — helps debug. */
45
+ upstreamStatus?: number;
46
+ /** Endpoint that failed — for ops. */
47
+ upstreamEndpoint?: string;
48
+ /**
49
+ * Set true when the upstream EXPLICITLY asked us to wait — i.e. a 429, or a
50
+ * 5xx that CARRIED a `Retry-After` header (ADR-0045 M2). The resilience layer
51
+ * (circuit breaker + path-chain) consults `isHonorRetryAfter(err)` and EXCLUDES
52
+ * such errors from the breaker failure count AND from fallback (B1-policy): we
53
+ * wait and fail honestly as `rate_limited`/`upstream_unavailable`, never route
54
+ * around a rate limit onto a mirror/snapshot. Absent (undefined) on a plain 5xx
55
+ * with no Retry-After header — that stays a HARD failure the breaker counts, so
56
+ * absence must NOT be read as `false`-meaning-"honor". The 429 path never sets
57
+ * this flag (it is detected by `kind==="rate_limited"`), keeping the 429 error
58
+ * envelope byte-identical to before this ADR.
59
+ */
60
+ honorRetryAfter?: boolean;
61
+ };
62
+
63
+ export type ToolResult<T> =
64
+ | { ok: true; data: T }
65
+ | { ok: false; error: ToolError };
66
+
67
+ const RATE_LIMIT_DEFAULT_SECONDS = 30;
68
+
69
+ export class ToolErrorCarrier extends Error {
70
+ readonly toolError: ToolError;
71
+ constructor(toolError: ToolError) {
72
+ super(toolError.message);
73
+ this.toolError = toolError;
74
+ this.name = "ToolErrorCarrier";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Convert a fetch Response into a structured tool error.
80
+ *
81
+ * Honors `Retry-After` (both seconds-int and HTTP-date forms).
82
+ */
83
+ export function errorFromResponse(
84
+ r: Response,
85
+ endpoint: string,
86
+ ): ToolError {
87
+ const upstreamStatus = r.status;
88
+ if (r.status === 429) {
89
+ const retryAfter = parseRetryAfter(r.headers.get("Retry-After"));
90
+ return {
91
+ kind: "rate_limited",
92
+ message: `Upstream rate-limited (HTTP 429) at ${endpoint}. Retry after ${retryAfter}s.`,
93
+ retryable: true,
94
+ retryAfterSeconds: retryAfter,
95
+ upstreamStatus,
96
+ upstreamEndpoint: endpoint,
97
+ };
98
+ }
99
+ if (r.status === 404) {
100
+ return {
101
+ kind: "not_found",
102
+ message: `Resource not found at ${endpoint} (HTTP 404).`,
103
+ retryable: false,
104
+ upstreamStatus,
105
+ upstreamEndpoint: endpoint,
106
+ };
107
+ }
108
+ if (r.status >= 500) {
109
+ const err: ToolError = {
110
+ kind: "upstream_unavailable",
111
+ message: `Upstream server error (HTTP ${r.status}) at ${endpoint}. Try again later.`,
112
+ retryable: true,
113
+ retryAfterSeconds: 60,
114
+ upstreamStatus,
115
+ upstreamEndpoint: endpoint,
116
+ };
117
+ // ADR-0045 M2 — a 5xx MAY carry Retry-After (e.g. a 503 maintenance window).
118
+ // Parse it ONLY when the header is PRESENT (so a plain 5xx is byte-identical
119
+ // to before: retryAfterSeconds:60, no honorRetryAfter key), preserving the
120
+ // `Math.min(...,60)` worst-case cap. The 429 branch above is UNTOUCHED (it
121
+ // already parses Retry-After; re-implementing it is forbidden). Flagging
122
+ // honorRetryAfter lets the resilience layer EXCLUDE this from the breaker /
123
+ // fallback (B1-policy: honor the explicit wait, never route around it).
124
+ const retryAfterHeader = r.headers.get("Retry-After");
125
+ if (retryAfterHeader !== null) {
126
+ err.retryAfterSeconds = Math.min(parseRetryAfter(retryAfterHeader), 60);
127
+ err.honorRetryAfter = true;
128
+ }
129
+ return err;
130
+ }
131
+ if (r.status >= 400) {
132
+ return {
133
+ kind: "invalid_input",
134
+ message: `Bad request (HTTP ${r.status}) at ${endpoint}.`,
135
+ retryable: false,
136
+ upstreamStatus,
137
+ upstreamEndpoint: endpoint,
138
+ };
139
+ }
140
+ return {
141
+ kind: "unknown",
142
+ message: `Unexpected status ${r.status} at ${endpoint}.`,
143
+ retryable: false,
144
+ upstreamStatus,
145
+ upstreamEndpoint: endpoint,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Does this thrown error carry an EXPLICIT "wait, then fail honestly" signal
151
+ * from the upstream — a 429 (`rate_limited`), or a 5xx that carried a
152
+ * `Retry-After` header (ADR-0045 M2, flagged `honorRetryAfter`)?
153
+ *
154
+ * The resilience layer (circuit breaker + path-chain, datasource.ts) consults
155
+ * this to EXCLUDE such errors from the breaker failure count AND from fallback
156
+ * (ADR-0045 B1-policy): a rate limit / honor-Retry-After outcome must NEVER
157
+ * count as a breaker "hard failure" nor trigger a mirror/snapshot fallback — we
158
+ * wait and fail honestly. This is a POLICY boundary, not a bypass: we honor the
159
+ * upstream's explicit throttle, we do not route around it.
160
+ */
161
+ export function isHonorRetryAfter(err: unknown): boolean {
162
+ if (!(err instanceof ToolErrorCarrier)) return false;
163
+ const te = err.toolError;
164
+ if (te.kind === "rate_limited") return true;
165
+ return te.honorRetryAfter === true;
166
+ }
167
+
168
+ /**
169
+ * Wrap a fetch + json call in retry-with-backoff for transient errors.
170
+ *
171
+ * Strategy: up to 3 attempts. On 429: respect Retry-After up to 60s.
172
+ * On 5xx: 1s, 2s, 4s exponential. On parse error: no retry (schema
173
+ * drift — needs human investigation).
174
+ */
175
+ export async function fetchWithRetry(
176
+ url: string,
177
+ init: RequestInit,
178
+ endpointLabel: string,
179
+ ): Promise<Response> {
180
+ const maxAttempts = 3;
181
+ let lastErr: ToolError | undefined;
182
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
183
+ try {
184
+ const r = await fetch(url, init);
185
+ if (r.ok) return r;
186
+ const err = errorFromResponse(r, endpointLabel);
187
+ if (!err.retryable || attempt === maxAttempts) {
188
+ throw new ToolErrorCarrier(err);
189
+ }
190
+ lastErr = err;
191
+ const wait = err.retryAfterSeconds
192
+ ? Math.min(err.retryAfterSeconds, 60)
193
+ : Math.pow(2, attempt - 1);
194
+ await new Promise((res) => setTimeout(res, wait * 1000));
195
+ } catch (e) {
196
+ if (e instanceof ToolErrorCarrier) throw e;
197
+ // A timeout/abort. The caller's AbortSignal.timeout fired (or an
198
+ // already-aborted signal is being reused across attempts). Retrying is
199
+ // futile within this call's budget: the same signal stays aborted, so
200
+ // attempts 2/3 reject immediately without ever reaching the endpoint,
201
+ // and a re-driven tool call just re-hits the same wall. Fail fast,
202
+ // honestly non-retryable. Keyed on the DOMException NAME only —
203
+ // AbortSignal.timeout → "TimeoutError", AbortController.abort() →
204
+ // "AbortError" — which is disjoint from a genuine network fault
205
+ // (TypeError, name "TypeError"), so a real "fetch failed" falls through
206
+ // to the generic retryable branch below UNCHANGED.
207
+ if (
208
+ e instanceof Error &&
209
+ (e.name === "TimeoutError" || e.name === "AbortError")
210
+ ) {
211
+ throw new ToolErrorCarrier({
212
+ kind: "upstream_unavailable",
213
+ message: `Request to ${endpointLabel} timed out.`,
214
+ retryable: false,
215
+ upstreamEndpoint: endpointLabel,
216
+ });
217
+ }
218
+ // Network-level error
219
+ lastErr = {
220
+ kind: "upstream_unavailable",
221
+ message: `Network error reaching ${endpointLabel}: ${(e as Error).message}`,
222
+ retryable: true,
223
+ retryAfterSeconds: 30,
224
+ upstreamEndpoint: endpointLabel,
225
+ };
226
+ if (attempt === maxAttempts) {
227
+ throw new ToolErrorCarrier(lastErr);
228
+ }
229
+ await new Promise((res) =>
230
+ setTimeout(res, Math.pow(2, attempt - 1) * 1000),
231
+ );
232
+ }
233
+ }
234
+ throw new ToolErrorCarrier(
235
+ lastErr ?? {
236
+ kind: "unknown",
237
+ message: `${endpointLabel} failed after ${maxAttempts} attempts.`,
238
+ retryable: false,
239
+ upstreamEndpoint: endpointLabel,
240
+ },
241
+ );
242
+ }
243
+
244
+ function parseRetryAfter(value: string | null): number {
245
+ if (!value) return RATE_LIMIT_DEFAULT_SECONDS;
246
+ const asInt = Number.parseInt(value, 10);
247
+ if (Number.isFinite(asInt)) return asInt;
248
+ // HTTP-date form
249
+ const date = Date.parse(value);
250
+ if (!Number.isNaN(date)) {
251
+ return Math.max(1, Math.ceil((date - Date.now()) / 1000));
252
+ }
253
+ return RATE_LIMIT_DEFAULT_SECONDS;
254
+ }
255
+
256
+ /**
257
+ * Convert any thrown error into a serializable ToolError envelope.
258
+ * Used at the dispatcher boundary — server.ts catches everything
259
+ * and wraps before returning to the MCP client.
260
+ */
261
+ export function toToolError(e: unknown, endpointLabel?: string): ToolError {
262
+ if (e instanceof ToolErrorCarrier) return e.toolError;
263
+ // A Zod input-validation failure is a CALLER error (e.g. limit above the max,
264
+ // a value outside an enum). Classify it as `invalid_input` with a readable
265
+ // field-level message — NEVER a generic `unknown` carrying Zod's raw JSON
266
+ // issue array (which an agent can't act on, and which mislabels a fixable
267
+ // input problem as a mysterious/possibly-transient failure).
268
+ if (e instanceof ZodError) {
269
+ return {
270
+ kind: "invalid_input",
271
+ message: `Invalid input${endpointLabel ? ` for ${endpointLabel}` : ""}: ${e.issues
272
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
273
+ .join("; ")}`,
274
+ retryable: false,
275
+ upstreamEndpoint: endpointLabel,
276
+ };
277
+ }
278
+ if (e instanceof Error) {
279
+ const msg = e.message;
280
+ // Common fetch timeout signature
281
+ if (e.name === "TimeoutError" || /timeout|aborted/i.test(msg)) {
282
+ return {
283
+ kind: "upstream_unavailable",
284
+ message: `${endpointLabel ?? "upstream"} timed out: ${msg}`,
285
+ retryable: true,
286
+ retryAfterSeconds: 30,
287
+ upstreamEndpoint: endpointLabel,
288
+ };
289
+ }
290
+ return {
291
+ kind: "unknown",
292
+ message: msg,
293
+ retryable: false,
294
+ upstreamEndpoint: endpointLabel,
295
+ };
296
+ }
297
+ return {
298
+ kind: "unknown",
299
+ message: String(e),
300
+ retryable: false,
301
+ upstreamEndpoint: endpointLabel,
302
+ };
303
+ }