@agent-os-sdk/client 0.3.14 → 0.4.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.
- package/dist/client/AgentOsClient.d.ts.map +1 -1
- package/dist/client/AgentOsClient.js +4 -5
- package/dist/client/config.d.ts +49 -0
- package/dist/client/config.d.ts.map +1 -0
- package/dist/client/config.js +62 -0
- package/dist/client/pagination.d.ts +105 -0
- package/dist/client/pagination.d.ts.map +1 -0
- package/dist/client/pagination.js +117 -0
- package/dist/client/raw.d.ts +65 -0
- package/dist/client/raw.d.ts.map +1 -1
- package/dist/client/raw.js +78 -17
- package/dist/client/retry.d.ts +37 -0
- package/dist/client/retry.d.ts.map +1 -0
- package/dist/client/retry.js +108 -0
- package/dist/client/timeout.d.ts +26 -0
- package/dist/client/timeout.d.ts.map +1 -0
- package/dist/client/timeout.js +51 -0
- package/dist/errors/factory.d.ts +20 -0
- package/dist/errors/factory.d.ts.map +1 -0
- package/dist/errors/factory.js +97 -0
- package/dist/errors/index.d.ts +210 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +283 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -0
- package/dist/modules/audit.d.ts +27 -4
- package/dist/modules/audit.d.ts.map +1 -1
- package/dist/modules/audit.js +58 -2
- package/dist/modules/catalog.d.ts +28 -4
- package/dist/modules/catalog.d.ts.map +1 -1
- package/dist/modules/catalog.js +15 -1
- package/dist/modules/checkpoints.d.ts +1 -1
- package/dist/modules/checkpoints.d.ts.map +1 -1
- package/dist/modules/info.d.ts +49 -0
- package/dist/modules/info.d.ts.map +1 -1
- package/dist/modules/info.js +66 -0
- package/dist/modules/runs.d.ts +103 -0
- package/dist/modules/runs.d.ts.map +1 -1
- package/dist/modules/runs.js +258 -0
- package/dist/modules/tenants.d.ts +4 -1
- package/dist/modules/tenants.d.ts.map +1 -1
- package/dist/modules/tenants.js +3 -0
- package/dist/modules/threads.d.ts +24 -0
- package/dist/modules/threads.d.ts.map +1 -1
- package/dist/modules/threads.js +48 -1
- package/dist/sse/client.d.ts.map +1 -1
- package/dist/sse/client.js +17 -5
- package/package.json +1 -1
- package/src/client/AgentOsClient.ts +4 -7
- package/src/client/config.ts +100 -0
- package/src/client/pagination.ts +218 -0
- package/src/client/raw.ts +138 -17
- package/src/client/retry.ts +150 -0
- package/src/client/timeout.ts +59 -0
- package/src/errors/factory.ts +135 -0
- package/src/errors/index.ts +365 -0
- package/src/index.ts +72 -2
- package/src/modules/audit.ts +77 -6
- package/src/modules/catalog.ts +38 -5
- package/src/modules/checkpoints.ts +1 -1
- package/src/modules/info.ts +108 -0
- package/src/modules/runs.ts +333 -0
- package/src/modules/tenants.ts +5 -2
- package/src/modules/threads.ts +57 -1
- package/src/sse/client.ts +21 -5
package/src/client/raw.ts
CHANGED
|
@@ -15,8 +15,47 @@ export type ClientOptions = {
|
|
|
15
15
|
baseUrl: string;
|
|
16
16
|
headers?: Record<string, string>;
|
|
17
17
|
headerProvider?: () => Promise<Record<string, string>>;
|
|
18
|
+
/** Hooks for observability (OTEL, Sentry, etc.) */
|
|
19
|
+
hooks?: SDKHooks;
|
|
18
20
|
};
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* SDK hooks for external observability tools.
|
|
24
|
+
* These are called during the request lifecycle for instrumentation.
|
|
25
|
+
*/
|
|
26
|
+
export interface SDKHooks {
|
|
27
|
+
/** Called before each request. Return modified request context or void. */
|
|
28
|
+
onRequest?: (context: HookRequestContext) => void | Promise<void>;
|
|
29
|
+
/** Called after each successful response */
|
|
30
|
+
onResponse?: (context: HookResponseContext) => void | Promise<void>;
|
|
31
|
+
/** Called on any error (network, timeout, HTTP error) */
|
|
32
|
+
onError?: (context: HookErrorContext) => void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HookRequestContext {
|
|
36
|
+
method: string;
|
|
37
|
+
url: string;
|
|
38
|
+
headers: Record<string, string>;
|
|
39
|
+
body?: unknown;
|
|
40
|
+
requestId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HookResponseContext {
|
|
44
|
+
method: string;
|
|
45
|
+
url: string;
|
|
46
|
+
status: number;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
requestId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HookErrorContext {
|
|
52
|
+
method: string;
|
|
53
|
+
url: string;
|
|
54
|
+
error: Error;
|
|
55
|
+
durationMs: number;
|
|
56
|
+
requestId?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
/**
|
|
21
60
|
* Standard API response wrapper.
|
|
22
61
|
* This matches the pattern used by openapi-fetch.
|
|
@@ -29,6 +68,11 @@ export interface APIResponse<T> {
|
|
|
29
68
|
details?: unknown;
|
|
30
69
|
};
|
|
31
70
|
response: Response;
|
|
71
|
+
/** Metadata for observability */
|
|
72
|
+
meta?: {
|
|
73
|
+
/** Request ID from x-request-id header */
|
|
74
|
+
requestId?: string;
|
|
75
|
+
};
|
|
32
76
|
}
|
|
33
77
|
|
|
34
78
|
/**
|
|
@@ -62,6 +106,11 @@ export function createRawClient(options: ClientOptions) {
|
|
|
62
106
|
params?: { path?: Record<string, string>; query?: Record<string, unknown> };
|
|
63
107
|
body?: unknown;
|
|
64
108
|
headers?: Record<string, string>;
|
|
109
|
+
signal?: AbortSignal;
|
|
110
|
+
/** Set to false to disable retry for this request */
|
|
111
|
+
retry?: boolean;
|
|
112
|
+
/** Override timeout for this request */
|
|
113
|
+
timeoutMs?: number;
|
|
65
114
|
}
|
|
66
115
|
): Promise<APIResponse<T>> {
|
|
67
116
|
// Replace path params
|
|
@@ -103,40 +152,112 @@ export function createRawClient(options: ClientOptions) {
|
|
|
103
152
|
headers["Content-Type"] = "application/json";
|
|
104
153
|
}
|
|
105
154
|
|
|
106
|
-
|
|
155
|
+
// Detect idempotency from header or body
|
|
156
|
+
const hasIdempotencyKey = Boolean(
|
|
157
|
+
headers["Idempotency-Key"] ||
|
|
158
|
+
headers["idempotency-key"] ||
|
|
159
|
+
(opts?.body && typeof opts.body === "object" && (opts.body as Record<string, unknown>).idempotency_key)
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Generate client request ID for tracing
|
|
163
|
+
const clientRequestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
164
|
+
|
|
165
|
+
// Call onRequest hook
|
|
166
|
+
const startTime = Date.now();
|
|
167
|
+
await options.hooks?.onRequest?.({
|
|
107
168
|
method,
|
|
169
|
+
url: fullUrl,
|
|
108
170
|
headers,
|
|
109
|
-
body: opts?.body
|
|
171
|
+
body: opts?.body,
|
|
172
|
+
requestId: clientRequestId,
|
|
110
173
|
});
|
|
111
174
|
|
|
175
|
+
// Execute the actual fetch
|
|
176
|
+
const doFetch = async (signal?: AbortSignal): Promise<Response> => {
|
|
177
|
+
return fetch(fullUrl, {
|
|
178
|
+
method,
|
|
179
|
+
headers,
|
|
180
|
+
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
|
181
|
+
signal,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let response: Response;
|
|
186
|
+
try {
|
|
187
|
+
response = await doFetch(opts?.signal);
|
|
188
|
+
} catch (fetchError) {
|
|
189
|
+
const durationMs = Date.now() - startTime;
|
|
190
|
+
// Call onError hook
|
|
191
|
+
await options.hooks?.onError?.({
|
|
192
|
+
method,
|
|
193
|
+
url: fullUrl,
|
|
194
|
+
error: fetchError instanceof Error ? fetchError : new Error(String(fetchError)),
|
|
195
|
+
durationMs,
|
|
196
|
+
requestId: clientRequestId,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Network error - return as error
|
|
200
|
+
return {
|
|
201
|
+
error: {
|
|
202
|
+
code: "NETWORK_ERROR",
|
|
203
|
+
message: fetchError instanceof Error ? fetchError.message : "Network request failed"
|
|
204
|
+
},
|
|
205
|
+
response: new Response(null, { status: 0 })
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const durationMs = Date.now() - startTime;
|
|
210
|
+
|
|
211
|
+
// Extract requestId for tracing (prefer server requestId if available)
|
|
212
|
+
const requestId = response.headers.get("x-request-id") ?? clientRequestId;
|
|
213
|
+
|
|
112
214
|
if (!response.ok) {
|
|
113
|
-
let
|
|
215
|
+
let errorBody: { code?: string; message?: string; details?: unknown } | undefined;
|
|
114
216
|
try {
|
|
115
|
-
|
|
116
|
-
error = {
|
|
117
|
-
code: json.code || `HTTP_${response.status}`,
|
|
118
|
-
message: json.message || response.statusText,
|
|
119
|
-
details: json.details,
|
|
120
|
-
};
|
|
217
|
+
errorBody = await response.json();
|
|
121
218
|
} catch {
|
|
122
|
-
|
|
123
|
-
code: `HTTP_${response.status}`,
|
|
124
|
-
message: response.statusText,
|
|
125
|
-
};
|
|
219
|
+
// Ignore JSON parse errors
|
|
126
220
|
}
|
|
127
|
-
|
|
221
|
+
|
|
222
|
+
// Call onError hook
|
|
223
|
+
await options.hooks?.onError?.({
|
|
224
|
+
method,
|
|
225
|
+
url: fullUrl,
|
|
226
|
+
error: new Error(errorBody?.message || response.statusText),
|
|
227
|
+
durationMs,
|
|
228
|
+
requestId,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
error: {
|
|
233
|
+
code: errorBody?.code || `HTTP_${response.status}`,
|
|
234
|
+
message: errorBody?.message || response.statusText,
|
|
235
|
+
details: errorBody?.details,
|
|
236
|
+
},
|
|
237
|
+
response,
|
|
238
|
+
meta: { requestId },
|
|
239
|
+
};
|
|
128
240
|
}
|
|
129
241
|
|
|
242
|
+
// Call onResponse hook
|
|
243
|
+
await options.hooks?.onResponse?.({
|
|
244
|
+
method,
|
|
245
|
+
url: fullUrl,
|
|
246
|
+
status: response.status,
|
|
247
|
+
durationMs,
|
|
248
|
+
requestId,
|
|
249
|
+
});
|
|
250
|
+
|
|
130
251
|
// Handle no-content responses
|
|
131
252
|
if (response.status === 204) {
|
|
132
|
-
return { data: undefined as T, response };
|
|
253
|
+
return { data: undefined as T, response, meta: { requestId } };
|
|
133
254
|
}
|
|
134
255
|
|
|
135
256
|
try {
|
|
136
257
|
const data = await response.json();
|
|
137
|
-
return { data: data as T, response };
|
|
258
|
+
return { data: data as T, response, meta: { requestId } };
|
|
138
259
|
} catch {
|
|
139
|
-
return { data: undefined as T, response };
|
|
260
|
+
return { data: undefined as T, response, meta: { requestId } };
|
|
140
261
|
}
|
|
141
262
|
}
|
|
142
263
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent OS SDK - Retry Logic
|
|
3
|
+
*
|
|
4
|
+
* Enterprise-grade retry with:
|
|
5
|
+
* - Exponential backoff with jitter
|
|
6
|
+
* - Respect for Retry-After headers
|
|
7
|
+
* - Idempotency-aware mutation retries
|
|
8
|
+
* - Proper abort signal handling (no listener leaks)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { AgentOsError, NetworkError, TimeoutError, RateLimitError } from "../errors/index.js";
|
|
12
|
+
import type { RetryConfig } from "./config.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Context for retry decision-making.
|
|
16
|
+
*/
|
|
17
|
+
export interface RetryContext {
|
|
18
|
+
/** HTTP method (GET, POST, etc.) */
|
|
19
|
+
method: string;
|
|
20
|
+
|
|
21
|
+
/** Whether the request has an idempotency key */
|
|
22
|
+
hasIdempotencyKey: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wraps an async function with retry logic.
|
|
27
|
+
*
|
|
28
|
+
* IMPORTANT: Mutations (POST/PUT/PATCH/DELETE) are only retried if hasIdempotencyKey is true.
|
|
29
|
+
* This prevents duplicate side effects.
|
|
30
|
+
*
|
|
31
|
+
* @param fn - Function to execute (receives an AbortSignal)
|
|
32
|
+
* @param config - Retry configuration
|
|
33
|
+
* @param context - Request context for retry decisions
|
|
34
|
+
* @param parentSignal - Optional parent AbortSignal for cancellation
|
|
35
|
+
*/
|
|
36
|
+
export async function withRetry<T>(
|
|
37
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
38
|
+
config: RetryConfig,
|
|
39
|
+
context: RetryContext,
|
|
40
|
+
parentSignal?: AbortSignal
|
|
41
|
+
): Promise<T> {
|
|
42
|
+
let lastError: Error | undefined;
|
|
43
|
+
|
|
44
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
45
|
+
// Check parent abort before each attempt
|
|
46
|
+
if (parentSignal?.aborted) {
|
|
47
|
+
throw new Error("Request aborted");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
|
|
52
|
+
// Proper abort propagation without listener leak
|
|
53
|
+
const onAbort = () => controller.abort();
|
|
54
|
+
parentSignal?.addEventListener("abort", onAbort, { once: true });
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
return await fn(controller.signal);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
lastError = err as Error;
|
|
60
|
+
|
|
61
|
+
// If parent aborted, don't retry
|
|
62
|
+
if (controller.signal.aborted && parentSignal?.aborted) {
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check if this error is retryable
|
|
67
|
+
if (!shouldRetry(err, config, context)) {
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Last attempt reached
|
|
72
|
+
if (attempt === config.maxRetries) {
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rate limit: use Retry-After if available
|
|
77
|
+
if (err instanceof RateLimitError && err.retryAfterMs) {
|
|
78
|
+
await sleep(err.retryAfterMs);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Exponential backoff with jitter
|
|
83
|
+
const delay = calculateBackoff(attempt, config);
|
|
84
|
+
await sleep(delay);
|
|
85
|
+
} finally {
|
|
86
|
+
// CRITICAL: Always cleanup listener to prevent memory leak
|
|
87
|
+
parentSignal?.removeEventListener("abort", onAbort);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw lastError ?? new Error("Retry failed");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Determines if an error should trigger a retry.
|
|
96
|
+
*/
|
|
97
|
+
function shouldRetry(
|
|
98
|
+
err: unknown,
|
|
99
|
+
config: RetryConfig,
|
|
100
|
+
context: RetryContext
|
|
101
|
+
): boolean {
|
|
102
|
+
// Network and timeout errors are always retryable
|
|
103
|
+
if (err instanceof NetworkError || err instanceof TimeoutError) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Unknown errors are not retryable
|
|
108
|
+
if (!(err instanceof AgentOsError)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if status code is in retryable list
|
|
113
|
+
if (!config.retryableStatuses.includes(err.status)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// CRITICAL: Only retry mutations if they have an idempotency key
|
|
118
|
+
// This prevents duplicate side effects from retrying POST/PUT/PATCH/DELETE
|
|
119
|
+
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(context.method.toUpperCase());
|
|
120
|
+
if (isMutation && !context.hasIdempotencyKey) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Calculates backoff delay with exponential increase and jitter.
|
|
129
|
+
*/
|
|
130
|
+
function calculateBackoff(attempt: number, config: RetryConfig): number {
|
|
131
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
132
|
+
const exponential = config.baseDelayMs * Math.pow(2, attempt);
|
|
133
|
+
|
|
134
|
+
// Cap at max delay
|
|
135
|
+
const capped = Math.min(exponential, config.maxDelayMs);
|
|
136
|
+
|
|
137
|
+
// Add jitter to prevent thundering herd
|
|
138
|
+
const jitter = capped * config.jitterFactor * Math.random();
|
|
139
|
+
|
|
140
|
+
return Math.floor(capped + jitter);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Sleep utility.
|
|
145
|
+
*/
|
|
146
|
+
function sleep(ms: number): Promise<void> {
|
|
147
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { sleep };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent OS SDK - Timeout Logic
|
|
3
|
+
*
|
|
4
|
+
* Wraps async operations with per-attempt timeout.
|
|
5
|
+
* Proper abort signal propagation without listener leaks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TimeoutError } from "../errors/index.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wraps an async function with a timeout.
|
|
12
|
+
*
|
|
13
|
+
* The timeout is per-attempt, not global. This ensures that slow attempts
|
|
14
|
+
* don't consume the entire retry budget.
|
|
15
|
+
*
|
|
16
|
+
* @param fn - Function to execute (receives an AbortSignal)
|
|
17
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
18
|
+
* @param parentSignal - Optional parent AbortSignal for cancellation
|
|
19
|
+
*/
|
|
20
|
+
export async function withTimeout<T>(
|
|
21
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
22
|
+
timeoutMs: number,
|
|
23
|
+
parentSignal?: AbortSignal
|
|
24
|
+
): Promise<T> {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
|
|
27
|
+
// Propagate parent abort
|
|
28
|
+
const onAbort = () => controller.abort();
|
|
29
|
+
parentSignal?.addEventListener("abort", onAbort, { once: true });
|
|
30
|
+
|
|
31
|
+
// Set timeout
|
|
32
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return await fn(controller.signal);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// If our timeout triggered the abort (not parent), throw TimeoutError
|
|
38
|
+
if (controller.signal.aborted && !parentSignal?.aborted) {
|
|
39
|
+
throw new TimeoutError(timeoutMs);
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timeoutId);
|
|
44
|
+
parentSignal?.removeEventListener("abort", onAbort);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates an AbortController that times out after the specified duration.
|
|
50
|
+
* Useful for creating deadline-aware operations.
|
|
51
|
+
*
|
|
52
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
53
|
+
* @returns AbortController that will abort after timeout
|
|
54
|
+
*/
|
|
55
|
+
export function createTimeoutController(timeoutMs: number): AbortController {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
setTimeout(() => controller.abort(), timeoutMs);
|
|
58
|
+
return controller;
|
|
59
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent OS SDK - Error Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates typed errors from HTTP responses.
|
|
5
|
+
* Preserves backend error codes and details for debugging.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AgentOsError,
|
|
10
|
+
UnauthorizedError,
|
|
11
|
+
ForbiddenError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
type FieldError,
|
|
18
|
+
type ErrorOptions,
|
|
19
|
+
} from "./index.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a typed error from an HTTP response.
|
|
23
|
+
*
|
|
24
|
+
* @param response - The fetch Response object
|
|
25
|
+
* @param body - Parsed JSON body (if available)
|
|
26
|
+
* @param requestPath - The original request path (for 404 errors)
|
|
27
|
+
*/
|
|
28
|
+
export function createErrorFromResponse(
|
|
29
|
+
response: Response,
|
|
30
|
+
body?: { code?: string; message?: string; details?: unknown },
|
|
31
|
+
requestPath?: string
|
|
32
|
+
): AgentOsError {
|
|
33
|
+
const status = response.status;
|
|
34
|
+
const message = body?.message || response.statusText || `HTTP ${status}`;
|
|
35
|
+
const requestId = response.headers.get("x-request-id") ?? undefined;
|
|
36
|
+
const opts: ErrorOptions = {
|
|
37
|
+
requestId,
|
|
38
|
+
backendCode: body?.code,
|
|
39
|
+
details: body?.details,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
switch (status) {
|
|
43
|
+
case 401:
|
|
44
|
+
return new UnauthorizedError(message, opts);
|
|
45
|
+
|
|
46
|
+
case 403:
|
|
47
|
+
return new ForbiddenError(message, opts);
|
|
48
|
+
|
|
49
|
+
case 404:
|
|
50
|
+
return new NotFoundError(message, requestPath, opts);
|
|
51
|
+
|
|
52
|
+
case 409:
|
|
53
|
+
return new ConflictError(message, opts);
|
|
54
|
+
|
|
55
|
+
case 400:
|
|
56
|
+
case 422:
|
|
57
|
+
return new ValidationError(
|
|
58
|
+
message,
|
|
59
|
+
status as 400 | 422,
|
|
60
|
+
parseFieldErrors(body?.details),
|
|
61
|
+
opts
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
case 429:
|
|
65
|
+
const retryAfterHeader = response.headers.get("Retry-After");
|
|
66
|
+
let retryAfterMs: number | undefined;
|
|
67
|
+
|
|
68
|
+
if (retryAfterHeader) {
|
|
69
|
+
// Retry-After can be seconds or a date
|
|
70
|
+
const seconds = parseInt(retryAfterHeader, 10);
|
|
71
|
+
if (!isNaN(seconds)) {
|
|
72
|
+
retryAfterMs = seconds * 1000;
|
|
73
|
+
} else {
|
|
74
|
+
// Try parsing as date
|
|
75
|
+
const date = new Date(retryAfterHeader);
|
|
76
|
+
if (!isNaN(date.getTime())) {
|
|
77
|
+
retryAfterMs = Math.max(0, date.getTime() - Date.now());
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return new RateLimitError(message, retryAfterMs, opts);
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
if (status >= 500) {
|
|
86
|
+
return new ServerError(message, status, opts);
|
|
87
|
+
}
|
|
88
|
+
// Fallback for unknown 4xx errors
|
|
89
|
+
return new ServerError(message, status, opts);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parses field errors from various backend formats.
|
|
95
|
+
*/
|
|
96
|
+
function parseFieldErrors(details: unknown): FieldError[] | undefined {
|
|
97
|
+
if (!details || typeof details !== "object") {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Format 1: Array of { field, message } objects
|
|
102
|
+
if (Array.isArray(details)) {
|
|
103
|
+
const errors = details.filter(
|
|
104
|
+
(d): d is FieldError =>
|
|
105
|
+
typeof d === "object" &&
|
|
106
|
+
d !== null &&
|
|
107
|
+
typeof d.field === "string" &&
|
|
108
|
+
typeof d.message === "string"
|
|
109
|
+
);
|
|
110
|
+
return errors.length > 0 ? errors : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Format 2: { errors: [...] }
|
|
114
|
+
if ("errors" in details && Array.isArray((details as { errors: unknown }).errors)) {
|
|
115
|
+
return parseFieldErrors((details as { errors: unknown[] }).errors);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Format 3: { field: [messages] } (Rails/Django style)
|
|
119
|
+
const entries = Object.entries(details);
|
|
120
|
+
if (entries.every(([, v]) => Array.isArray(v))) {
|
|
121
|
+
const errors: FieldError[] = [];
|
|
122
|
+
for (const [field, messages] of entries) {
|
|
123
|
+
if (Array.isArray(messages)) {
|
|
124
|
+
for (const message of messages) {
|
|
125
|
+
if (typeof message === "string") {
|
|
126
|
+
errors.push({ field, message });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return errors.length > 0 ? errors : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|