@agent-os-sdk/client 0.3.15 → 0.4.1

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 (66) hide show
  1. package/dist/client/config.d.ts +49 -0
  2. package/dist/client/config.d.ts.map +1 -0
  3. package/dist/client/config.js +62 -0
  4. package/dist/client/pagination.d.ts +105 -0
  5. package/dist/client/pagination.d.ts.map +1 -0
  6. package/dist/client/pagination.js +117 -0
  7. package/dist/client/raw.d.ts +67 -2
  8. package/dist/client/raw.d.ts.map +1 -1
  9. package/dist/client/raw.js +78 -17
  10. package/dist/client/retry.d.ts +37 -0
  11. package/dist/client/retry.d.ts.map +1 -0
  12. package/dist/client/retry.js +108 -0
  13. package/dist/client/timeout.d.ts +26 -0
  14. package/dist/client/timeout.d.ts.map +1 -0
  15. package/dist/client/timeout.js +51 -0
  16. package/dist/errors/factory.d.ts +20 -0
  17. package/dist/errors/factory.d.ts.map +1 -0
  18. package/dist/errors/factory.js +97 -0
  19. package/dist/errors/index.d.ts +210 -0
  20. package/dist/errors/index.d.ts.map +1 -0
  21. package/dist/errors/index.js +283 -0
  22. package/dist/index.d.ts +37 -29
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +48 -22
  25. package/dist/modules/audit.d.ts +27 -4
  26. package/dist/modules/audit.d.ts.map +1 -1
  27. package/dist/modules/audit.js +58 -2
  28. package/dist/modules/builder.d.ts +6 -0
  29. package/dist/modules/builder.d.ts.map +1 -1
  30. package/dist/modules/checkpoints.d.ts +1 -1
  31. package/dist/modules/checkpoints.d.ts.map +1 -1
  32. package/dist/modules/info.d.ts +49 -0
  33. package/dist/modules/info.d.ts.map +1 -1
  34. package/dist/modules/info.js +66 -0
  35. package/dist/modules/members.d.ts +50 -2
  36. package/dist/modules/members.d.ts.map +1 -1
  37. package/dist/modules/members.js +61 -0
  38. package/dist/modules/runs.d.ts +103 -0
  39. package/dist/modules/runs.d.ts.map +1 -1
  40. package/dist/modules/runs.js +258 -0
  41. package/dist/modules/tenants.d.ts +4 -1
  42. package/dist/modules/tenants.d.ts.map +1 -1
  43. package/dist/modules/tenants.js +3 -0
  44. package/dist/modules/threads.d.ts +24 -0
  45. package/dist/modules/threads.d.ts.map +1 -1
  46. package/dist/modules/threads.js +48 -1
  47. package/dist/sse/client.d.ts.map +1 -1
  48. package/dist/sse/client.js +17 -5
  49. package/package.json +49 -50
  50. package/src/client/config.ts +100 -0
  51. package/src/client/pagination.ts +218 -0
  52. package/src/client/raw.ts +141 -20
  53. package/src/client/retry.ts +150 -0
  54. package/src/client/timeout.ts +59 -0
  55. package/src/errors/factory.ts +135 -0
  56. package/src/errors/index.ts +365 -0
  57. package/src/index.ts +97 -76
  58. package/src/modules/audit.ts +77 -6
  59. package/src/modules/builder.ts +7 -0
  60. package/src/modules/checkpoints.ts +1 -1
  61. package/src/modules/info.ts +108 -0
  62. package/src/modules/members.ts +80 -2
  63. package/src/modules/runs.ts +333 -0
  64. package/src/modules/tenants.ts +5 -2
  65. package/src/modules/threads.ts +57 -1
  66. package/src/sse/client.ts +21 -5
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Agent OS SDK - Network Configuration
3
+ *
4
+ * Centralized configuration for timeouts, retries, and network policies.
5
+ */
6
+
7
+ /**
8
+ * Retry configuration for network requests.
9
+ */
10
+ export interface RetryConfig {
11
+ /** Maximum retry attempts (default: 3) */
12
+ maxRetries: number;
13
+
14
+ /** Base delay between retries in ms (default: 1000) */
15
+ baseDelayMs: number;
16
+
17
+ /** Maximum delay between retries in ms (default: 30000) */
18
+ maxDelayMs: number;
19
+
20
+ /** Jitter factor to prevent thundering herd (0-1, default: 0.2) */
21
+ jitterFactor: number;
22
+
23
+ /** HTTP status codes that trigger retry (default: [408, 429, 500, 502, 503, 504]) */
24
+ retryableStatuses: number[];
25
+ }
26
+
27
+ /**
28
+ * Full network configuration for the SDK client.
29
+ */
30
+ export interface NetworkConfig {
31
+ /** Timeout per request attempt in milliseconds (default: 30000) */
32
+ timeoutMs: number;
33
+
34
+ /** Retry configuration */
35
+ retry: RetryConfig;
36
+ }
37
+
38
+ /**
39
+ * Default network configuration.
40
+ * Balanced for most use cases - 30s timeout, 3 retries with exponential backoff.
41
+ */
42
+ export const DEFAULT_NETWORK_CONFIG: NetworkConfig = {
43
+ timeoutMs: 30_000,
44
+ retry: {
45
+ maxRetries: 3,
46
+ baseDelayMs: 1_000,
47
+ maxDelayMs: 30_000,
48
+ jitterFactor: 0.2,
49
+ retryableStatuses: [408, 429, 500, 502, 503, 504],
50
+ },
51
+ };
52
+
53
+ /**
54
+ * Aggressive configuration for interactive UIs.
55
+ * Shorter timeouts, fewer retries.
56
+ */
57
+ export const INTERACTIVE_NETWORK_CONFIG: NetworkConfig = {
58
+ timeoutMs: 10_000,
59
+ retry: {
60
+ maxRetries: 1,
61
+ baseDelayMs: 500,
62
+ maxDelayMs: 5_000,
63
+ jitterFactor: 0.1,
64
+ retryableStatuses: [429, 503, 504],
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Patient configuration for background jobs.
70
+ * Longer timeouts, more retries.
71
+ */
72
+ export const BACKGROUND_NETWORK_CONFIG: NetworkConfig = {
73
+ timeoutMs: 120_000,
74
+ retry: {
75
+ maxRetries: 5,
76
+ baseDelayMs: 2_000,
77
+ maxDelayMs: 60_000,
78
+ jitterFactor: 0.3,
79
+ retryableStatuses: [408, 429, 500, 502, 503, 504],
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Merges user config with defaults.
85
+ */
86
+ export function mergeNetworkConfig(
87
+ userConfig?: Partial<NetworkConfig>
88
+ ): NetworkConfig {
89
+ if (!userConfig) {
90
+ return DEFAULT_NETWORK_CONFIG;
91
+ }
92
+
93
+ return {
94
+ timeoutMs: userConfig.timeoutMs ?? DEFAULT_NETWORK_CONFIG.timeoutMs,
95
+ retry: {
96
+ ...DEFAULT_NETWORK_CONFIG.retry,
97
+ ...userConfig.retry,
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Agent OS SDK - Pagination Utilities
3
+ *
4
+ * Auto-paginating iterators that support both offset and cursor pagination.
5
+ * Designed to work with any list endpoint.
6
+ */
7
+
8
+ import type { APIResponse } from "./raw.js";
9
+
10
+ // ============================================================================
11
+ // Response Types
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Offset-based paginated response.
16
+ * Most common format for list endpoints.
17
+ */
18
+ export interface OffsetPaginatedResponse<T> {
19
+ items: T[];
20
+ total: number;
21
+ }
22
+
23
+ /**
24
+ * Cursor-based paginated response.
25
+ * Better for large datasets and real-time consistency.
26
+ */
27
+ export interface CursorPaginatedResponse<T> {
28
+ items: T[];
29
+ next_cursor?: string;
30
+ has_more: boolean;
31
+ }
32
+
33
+ /**
34
+ * Union type for both pagination styles.
35
+ */
36
+ export type PaginatedResponse<T> =
37
+ | OffsetPaginatedResponse<T>
38
+ | CursorPaginatedResponse<T>;
39
+
40
+ // ============================================================================
41
+ // Parameter Types
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Offset pagination parameters.
46
+ */
47
+ export interface OffsetParams {
48
+ limit?: number;
49
+ offset?: number;
50
+ }
51
+
52
+ /**
53
+ * Cursor pagination parameters.
54
+ */
55
+ export interface CursorParams {
56
+ limit?: number;
57
+ after?: string;
58
+ }
59
+
60
+ /**
61
+ * Combined pagination parameters.
62
+ */
63
+ export type PaginationParams = OffsetParams | CursorParams;
64
+
65
+ // ============================================================================
66
+ // Type Guards
67
+ // ============================================================================
68
+
69
+ /**
70
+ * Checks if response uses cursor pagination.
71
+ */
72
+ function isCursorResponse<T>(response: PaginatedResponse<T>): response is CursorPaginatedResponse<T> {
73
+ return "has_more" in response;
74
+ }
75
+
76
+ // ============================================================================
77
+ // Paginate Function
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Options for pagination behavior.
82
+ */
83
+ export interface PaginateOptions {
84
+ /** Number of items per page (default: 100) */
85
+ pageSize?: number;
86
+
87
+ /** Maximum total items to fetch (default: unlimited) */
88
+ maxItems?: number;
89
+
90
+ /** AbortSignal for cancellation */
91
+ signal?: AbortSignal;
92
+ }
93
+
94
+ /**
95
+ * Creates an async iterator that automatically paginates through results.
96
+ * Supports both offset and cursor pagination transparently.
97
+ *
98
+ * @example
99
+ * // Basic usage
100
+ * for await (const run of paginate(
101
+ * (p) => client.runs.list(p),
102
+ * { status: "completed" }
103
+ * )) {
104
+ * console.log(run.id);
105
+ * }
106
+ *
107
+ * @example
108
+ * // With options
109
+ * for await (const agent of paginate(
110
+ * (p) => client.agents.list(p),
111
+ * { workspace_id: "ws_123" },
112
+ * { pageSize: 50, maxItems: 200 }
113
+ * )) {
114
+ * console.log(agent.name);
115
+ * }
116
+ */
117
+ export async function* paginate<T, P extends PaginationParams>(
118
+ fetchPage: (params: P) => Promise<APIResponse<PaginatedResponse<T>>>,
119
+ baseParams: Omit<P, "limit" | "offset" | "after">,
120
+ options?: PaginateOptions
121
+ ): AsyncGenerator<T, void, unknown> {
122
+ const pageSize = options?.pageSize ?? 100;
123
+ const maxItems = options?.maxItems ?? Infinity;
124
+ const signal = options?.signal;
125
+
126
+ let offset = 0;
127
+ let cursor: string | undefined;
128
+ let hasMore = true;
129
+ let yielded = 0;
130
+
131
+ while (hasMore && yielded < maxItems) {
132
+ // Check for cancellation
133
+ if (signal?.aborted) {
134
+ return;
135
+ }
136
+
137
+ // Build params based on pagination style
138
+ const params = {
139
+ ...baseParams,
140
+ limit: Math.min(pageSize, maxItems - yielded),
141
+ ...(cursor !== undefined ? { after: cursor } : { offset }),
142
+ } as P;
143
+
144
+ const response = await fetchPage(params);
145
+
146
+ if (response.error) {
147
+ throw response.error;
148
+ }
149
+
150
+ const data = response.data!;
151
+
152
+ for (const item of data.items) {
153
+ if (yielded >= maxItems) {
154
+ return;
155
+ }
156
+ yield item;
157
+ yielded++;
158
+ }
159
+
160
+ // Update pagination state based on response type
161
+ if (isCursorResponse(data)) {
162
+ cursor = data.next_cursor;
163
+ hasMore = data.has_more && data.items.length > 0;
164
+ } else {
165
+ offset += data.items.length;
166
+ hasMore = offset < data.total && data.items.length > 0;
167
+ }
168
+ }
169
+ }
170
+
171
+ // ============================================================================
172
+ // Collect Utility
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Collects all items from a paginated endpoint into an array.
177
+ * Useful when you need all items at once.
178
+ *
179
+ * @example
180
+ * const allRuns = await collectAll(
181
+ * (p) => client.runs.list(p),
182
+ * { status: "completed" },
183
+ * { maxItems: 1000 }
184
+ * );
185
+ */
186
+ export async function collectAll<T, P extends PaginationParams>(
187
+ fetchPage: (params: P) => Promise<APIResponse<PaginatedResponse<T>>>,
188
+ baseParams: Omit<P, "limit" | "offset" | "after">,
189
+ options?: PaginateOptions
190
+ ): Promise<T[]> {
191
+ const items: T[] = [];
192
+
193
+ for await (const item of paginate(fetchPage, baseParams, options)) {
194
+ items.push(item);
195
+ }
196
+
197
+ return items;
198
+ }
199
+
200
+ /**
201
+ * Gets the first item from a paginated endpoint.
202
+ * More efficient than collecting all items when you only need one.
203
+ *
204
+ * @example
205
+ * const latestRun = await getFirst(
206
+ * (p) => client.runs.list({ ...p, order: "desc" }),
207
+ * { agent_id: "agent_123" }
208
+ * );
209
+ */
210
+ export async function getFirst<T, P extends PaginationParams>(
211
+ fetchPage: (params: P) => Promise<APIResponse<PaginatedResponse<T>>>,
212
+ baseParams: Omit<P, "limit" | "offset" | "after">
213
+ ): Promise<T | undefined> {
214
+ for await (const item of paginate(fetchPage, baseParams, { pageSize: 1, maxItems: 1 })) {
215
+ return item;
216
+ }
217
+ return undefined;
218
+ }
package/src/client/raw.ts CHANGED
@@ -5,18 +5,57 @@
5
5
  * All types are automatically inferred from the backend Swagger specification.
6
6
  */
7
7
 
8
- import createClient, { type Client, type FetchOptions } from "openapi-fetch";
9
- import type { paths, components } from "../generated/openapi.js";
8
+ import createClient, { type Client } from "openapi-fetch";
9
+ import type { components, paths } from "../generated/openapi.js";
10
10
 
11
11
  // Re-export types for external use
12
- export type { paths, components };
12
+ export type { components, paths };
13
13
 
14
14
  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
- const response = await fetch(fullUrl, {
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 ? JSON.stringify(opts.body) : undefined,
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: 503, statusText: "Network Error" })
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 error: APIResponse<T>["error"];
215
+ let errorBody: { code?: string; message?: string; details?: unknown } | undefined;
114
216
  try {
115
- const json = await response.json();
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
- error = {
123
- code: `HTTP_${response.status}`,
124
- message: response.statusText,
125
- };
219
+ // Ignore JSON parse errors
126
220
  }
127
- return { error, response };
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 };