ai-retry 0.3.0 → 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.
package/README.md CHANGED
@@ -171,6 +171,39 @@ const retryable = createRetryable({
171
171
  });
172
172
  ```
173
173
 
174
+ #### Retry After Delay
175
+
176
+ Handle retryable errors with delays and respect `retry-after` headers from rate-limited responses. This is useful for handling 429 (Too Many Requests) and 503 (Service Unavailable) errors.
177
+
178
+ > [!NOTE]
179
+ > If the response contains a [`retry-after`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After) header, it will be prioritized over the configured delay.
180
+
181
+
182
+ ```typescript
183
+ import { retryAfterDelay } from 'ai-retry/retryables';
184
+
185
+ const retryableModel = createRetryable({
186
+ model: openai('gpt-4'), // Base model
187
+ retries: [
188
+ // Retry base model 3 times with fixed 2s delay
189
+ retryAfterDelay({ delay: 2000, maxAttempts: 3 }),
190
+
191
+ // Or retry with exponential backoff (2s, 4s, 8s)
192
+ retryAfterDelay({ delay: 2000, backoffFactor: 2, maxAttempts: 3 }),
193
+
194
+ // Or switch to a different model after delay
195
+ retryAfterDelay(openai('gpt-4-mini'), { delay: 1000 }),
196
+ ],
197
+ });
198
+ ```
199
+
200
+ **Options:**
201
+ - `delay` (required): Delay in milliseconds before retrying
202
+ - `backoffFactor` (optional): Multiplier for exponential backoff (delay × backoffFactor^attempt). If not provided, uses fixed delay.
203
+ - `maxAttempts` (optional): Maximum number of retry attempts for this model
204
+
205
+ By default, if a [`retry-after-ms`](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/provisioned-get-started#what-should--i-do-when-i-receive-a-429-response) or `retry-after` header is present in the response, it will be prioritized over the configured delay. The delay from the header will be capped at 60 seconds for safety. If no headers are present, the configured delay or exponential backoff will be used.
206
+
174
207
  #### Fallbacks
175
208
 
176
209
  If you always want to fallback to a different model on any error, you can simply provide a list of models.
@@ -247,6 +280,8 @@ try {
247
280
  }
248
281
  ```
249
282
 
283
+ ### Options
284
+
250
285
  #### Retry Delays
251
286
 
252
287
  You can add delays before retrying to handle rate limiting or give services time to recover. The delay respects abort signals, so requests can still be cancelled during the delay period.
@@ -267,6 +302,13 @@ const retryableModel = createRetryable({
267
302
  }),
268
303
  ],
269
304
  });
305
+
306
+ const result = await generateText({
307
+ model: retryableModel,
308
+ prompt: 'Write a vegetarian lasagna recipe for 4 people.',
309
+ // Will be respected during delays
310
+ abortSignal: AbortSignal.timeout(60_000),
311
+ });
270
312
  ```
271
313
 
272
314
  You can also use delays with built-in retryables:
@@ -283,6 +325,26 @@ const retryableModel = createRetryable({
283
325
  });
284
326
  ```
285
327
 
328
+ #### Max Attempts
329
+
330
+ By default, each retryable will only attempt to retry once per model to avoid infinite loops. You can customize this behavior by returning a `maxAttempts` value from your retryable function. Note that the initial request with the base model is counted as the first attempt.
331
+
332
+ ```typescript
333
+ const retryableModel = createRetryable({
334
+ model: openai('gpt-4'),
335
+ retries: [
336
+ // Try this once
337
+ anthropic('claude-3-haiku-20240307'),
338
+ // Try this one more time (initial + 1 retry)
339
+ () => ({ model: openai('gpt-4'), maxAttempts: 2 }),
340
+ // Already tried, won't be retried again
341
+ anthropic('claude-3-haiku-20240307')
342
+ ],
343
+ });
344
+ ```
345
+
346
+ The attempts are counted per unique model (provider + modelId). That means if multiple retryables return the same model, it won't be retried again once the `maxAttempts` is reached.
347
+
286
348
  #### Logging
287
349
 
288
350
  You can use the following callbacks to log retry attempts and errors:
@@ -321,11 +383,10 @@ There are several built-in retryables:
321
383
  - [`contentFilterTriggered`](./src/retryables/content-filter-triggered.ts): Content filter was triggered based on the prompt or completion.
322
384
  - [`requestTimeout`](./src/retryables/request-timeout.ts): Request timeout occurred.
323
385
  - [`requestNotRetryable`](./src/retryables/request-not-retryable.ts): Request failed with a non-retryable error.
386
+ - [`retryAfterDelay`](./src/retryables/retry-after-delay.ts): Retry with exponential backoff and respect `retry-after` headers for rate limiting.
324
387
  - [`serviceOverloaded`](./src/retryables/service-overloaded.ts): Response with status code 529 (service overloaded).
325
388
  - Use this retryable to handle Anthropic's overloaded errors.
326
389
 
327
- By default, each retryable will only attempt to retry once per model to avoid infinite loops. You can customize this behavior by returning a `maxAttempts` value from your retryable function.
328
-
329
390
  ### API Reference
330
391
 
331
392
  #### `createRetryable(options: RetryableModelOptions): LanguageModelV2 | EmbeddingModelV2`
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { EmbeddingModelV2, EmbeddingModelV2CallOptions, EmbeddingModelV2Embed, LanguageModelV2, LanguageModelV2Generate, LanguageModelV2Stream, Retries, RetryAttempt, RetryContext, RetryErrorAttempt, RetryModel, RetryResultAttempt, Retryable, RetryableModelOptions } from "./types-BrJaHkFh.js";
2
+ import * as _ai_sdk_provider0 from "@ai-sdk/provider";
3
+ import { LanguageModelV2StreamPart } from "@ai-sdk/provider";
2
4
 
3
5
  //#region src/create-retryable-model.d.ts
4
6
  declare function createRetryable<MODEL extends LanguageModelV2>(options: RetryableModelOptions<MODEL>): LanguageModelV2;
@@ -10,4 +12,56 @@ declare function createRetryable<MODEL extends EmbeddingModelV2>(options: Retrya
10
12
  */
11
13
  declare const getModelKey: (model: LanguageModelV2 | EmbeddingModelV2) => string;
12
14
  //#endregion
13
- export { EmbeddingModelV2, EmbeddingModelV2CallOptions, EmbeddingModelV2Embed, LanguageModelV2, LanguageModelV2Generate, LanguageModelV2Stream, Retries, RetryAttempt, RetryContext, RetryErrorAttempt, RetryModel, RetryResultAttempt, Retryable, RetryableModelOptions, createRetryable, getModelKey };
15
+ //#region src/utils.d.ts
16
+ declare const isObject: (value: unknown) => value is Record<string, unknown>;
17
+ declare const isString: (value: unknown) => value is string;
18
+ declare const isStreamResult: (result: LanguageModelV2Generate | LanguageModelV2Stream) => result is LanguageModelV2Stream;
19
+ declare const isGenerateResult: (result: LanguageModelV2Generate | LanguageModelV2Stream) => result is LanguageModelV2Generate;
20
+ /**
21
+ * Type guard to check if a retry attempt is an error attempt
22
+ */
23
+ declare function isErrorAttempt(attempt: RetryAttempt<any>): attempt is RetryErrorAttempt<any>;
24
+ /**
25
+ * Type guard to check if a retry attempt is a result attempt
26
+ */
27
+ declare function isResultAttempt(attempt: RetryAttempt<any>): attempt is RetryResultAttempt;
28
+ /**
29
+ * Check if a stream part is a content part (e.g., text delta, reasoning delta, source, tool call, tool result).
30
+ * These types are also emitted by `onChunk` callbacks.
31
+ * @see https://github.com/vercel/ai/blob/1fe4bd4144bff927f5319d9d206e782a73979ccb/packages/ai/src/generate-text/stream-text.ts#L686-L697
32
+ */
33
+ declare const isStreamContentPart: (part: LanguageModelV2StreamPart) => part is _ai_sdk_provider0.LanguageModelV2Source | _ai_sdk_provider0.LanguageModelV2ToolCall | {
34
+ type: "tool-result";
35
+ toolCallId: string;
36
+ toolName: string;
37
+ result: unknown;
38
+ isError?: boolean;
39
+ providerExecuted?: boolean;
40
+ providerMetadata?: _ai_sdk_provider0.SharedV2ProviderMetadata;
41
+ } | {
42
+ type: "text-delta";
43
+ id: string;
44
+ providerMetadata?: _ai_sdk_provider0.SharedV2ProviderMetadata;
45
+ delta: string;
46
+ } | {
47
+ type: "reasoning-delta";
48
+ id: string;
49
+ providerMetadata?: _ai_sdk_provider0.SharedV2ProviderMetadata;
50
+ delta: string;
51
+ } | {
52
+ type: "tool-input-start";
53
+ id: string;
54
+ toolName: string;
55
+ providerMetadata?: _ai_sdk_provider0.SharedV2ProviderMetadata;
56
+ providerExecuted?: boolean;
57
+ } | {
58
+ type: "tool-input-delta";
59
+ id: string;
60
+ delta: string;
61
+ providerMetadata?: _ai_sdk_provider0.SharedV2ProviderMetadata;
62
+ } | {
63
+ type: "raw";
64
+ rawValue: unknown;
65
+ };
66
+ //#endregion
67
+ export { EmbeddingModelV2, EmbeddingModelV2CallOptions, EmbeddingModelV2Embed, LanguageModelV2, LanguageModelV2Generate, LanguageModelV2Stream, Retries, RetryAttempt, RetryContext, RetryErrorAttempt, RetryModel, RetryResultAttempt, Retryable, RetryableModelOptions, createRetryable, getModelKey, isErrorAttempt, isGenerateResult, isObject, isResultAttempt, isStreamContentPart, isStreamResult, isString };
package/dist/index.js CHANGED
@@ -1,17 +1,8 @@
1
- import { isErrorAttempt, isGenerateResult, isResultAttempt, isStreamContentPart } from "./utils-lRsC105f.js";
1
+ import { getModelKey, isErrorAttempt, isGenerateResult, isObject, isResultAttempt, isStreamContentPart, isStreamResult, isString } from "./utils-Dojn0elD.js";
2
2
  import { delay } from "@ai-sdk/provider-utils";
3
3
  import { getErrorMessage } from "@ai-sdk/provider";
4
4
  import { RetryError } from "ai";
5
5
 
6
- //#region src/get-model-key.ts
7
- /**
8
- * Generate a unique key for a LanguageModelV2 instance.
9
- */
10
- const getModelKey = (model) => {
11
- return `${model.provider}/${model.modelId}`;
12
- };
13
-
14
- //#endregion
15
6
  //#region src/find-retry-model.ts
16
7
  /**
17
8
  * Find the next model to retry with based on the retry context
@@ -415,4 +406,4 @@ function createRetryable(options) {
415
406
  }
416
407
 
417
408
  //#endregion
418
- export { createRetryable, getModelKey };
409
+ export { createRetryable, getModelKey, isErrorAttempt, isGenerateResult, isObject, isResultAttempt, isStreamContentPart, isStreamResult, isString };
@@ -21,6 +21,19 @@ declare function requestNotRetryable<MODEL extends LanguageModelV2 | EmbeddingMo
21
21
  */
22
22
  declare function requestTimeout<MODEL extends LanguageModelV2 | EmbeddingModelV2>(model: MODEL, options?: Omit<RetryModel<MODEL>, 'model'>): Retryable<MODEL>;
23
23
  //#endregion
24
+ //#region src/retryables/retry-after-delay.d.ts
25
+ type RetryAfterDelayOptions<MODEL extends LanguageModelV2 | EmbeddingModelV2> = Omit<RetryModel<MODEL>, 'model' | 'delay'> & {
26
+ delay: number;
27
+ backoffFactor?: number;
28
+ };
29
+ /**
30
+ * Retry with the same or a different model if the error is retryable with a delay.
31
+ * Uses the `Retry-After` or `Retry-After-Ms` headers if present.
32
+ * Otherwise uses the specified `delay`, or exponential backoff if `backoffFactor` is provided.
33
+ */
34
+ declare function retryAfterDelay<MODEL extends LanguageModelV2 | EmbeddingModelV2>(model: MODEL, options?: RetryAfterDelayOptions<MODEL>): Retryable<MODEL>;
35
+ declare function retryAfterDelay<MODEL extends LanguageModelV2 | EmbeddingModelV2>(options: RetryAfterDelayOptions<MODEL>): Retryable<MODEL>;
36
+ //#endregion
24
37
  //#region src/retryables/service-overloaded.d.ts
25
38
  /**
26
39
  * Fallback to a different model if the provider returns an overloaded error.
@@ -31,4 +44,4 @@ declare function requestTimeout<MODEL extends LanguageModelV2 | EmbeddingModelV2
31
44
  */
32
45
  declare function serviceOverloaded<MODEL extends LanguageModelV2 | EmbeddingModelV2>(model: MODEL, options?: Omit<RetryModel<MODEL>, 'model'>): Retryable<MODEL>;
33
46
  //#endregion
34
- export { contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
47
+ export { contentFilterTriggered, requestNotRetryable, requestTimeout, retryAfterDelay, serviceOverloaded };
@@ -1,4 +1,4 @@
1
- import { isErrorAttempt, isObject, isResultAttempt, isString } from "../utils-lRsC105f.js";
1
+ import { getModelKey, isErrorAttempt, isObject, isResultAttempt, isString } from "../utils-Dojn0elD.js";
2
2
  import { isAbortError } from "@ai-sdk/provider-utils";
3
3
  import { APICallError } from "ai";
4
4
 
@@ -69,6 +69,68 @@ function requestTimeout(model, options) {
69
69
  };
70
70
  }
71
71
 
72
+ //#endregion
73
+ //#region src/calculate-exponential-backoff.ts
74
+ /**
75
+ * Calculates the exponential backoff delay.
76
+ */
77
+ function calculateExponentialBackoff(baseDelay, backoffFactor, attempts) {
78
+ return baseDelay * backoffFactor ** attempts;
79
+ }
80
+
81
+ //#endregion
82
+ //#region src/parse-retry-headers.ts
83
+ function parseRetryHeaders(headers) {
84
+ if (!headers) return null;
85
+ const retryAfterMs = headers["retry-after-ms"];
86
+ if (retryAfterMs) {
87
+ const delayMs = Number.parseFloat(retryAfterMs);
88
+ if (!Number.isNaN(delayMs) && delayMs >= 0) return delayMs;
89
+ }
90
+ const retryAfter = headers["retry-after"];
91
+ if (retryAfter) {
92
+ const seconds = Number.parseFloat(retryAfter);
93
+ if (!Number.isNaN(seconds)) return seconds * 1e3;
94
+ const date = Date.parse(retryAfter);
95
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
96
+ }
97
+ return null;
98
+ }
99
+
100
+ //#endregion
101
+ //#region src/retryables/retry-after-delay.ts
102
+ const MAX_RETRY_AFTER_MS = 6e4;
103
+ function retryAfterDelay(modelOrOptions, options) {
104
+ const model = modelOrOptions && "delay" in modelOrOptions ? void 0 : modelOrOptions;
105
+ const opts = modelOrOptions && "delay" in modelOrOptions ? modelOrOptions : options;
106
+ if (!opts?.delay) throw new Error("retryAfterDelay: delay is required");
107
+ const delay$1 = opts.delay;
108
+ const backoffFactor = Math.max(opts.backoffFactor ?? 1, 1);
109
+ return (context) => {
110
+ const { current, attempts } = context;
111
+ if (isErrorAttempt(current)) {
112
+ const { error } = current;
113
+ if (APICallError.isInstance(error) && error.isRetryable === true) {
114
+ const targetModel = model ?? current.model;
115
+ const modelKey = getModelKey(targetModel);
116
+ const modelAttempts = attempts.filter((a) => getModelKey(a.model) === modelKey);
117
+ const headerDelay = parseRetryHeaders(error.responseHeaders);
118
+ if (headerDelay !== null) return {
119
+ model: targetModel,
120
+ delay: Math.min(headerDelay, MAX_RETRY_AFTER_MS),
121
+ maxAttempts: opts.maxAttempts
122
+ };
123
+ const calculatedDelay = calculateExponentialBackoff(delay$1, backoffFactor, modelAttempts.length);
124
+ return {
125
+ model: targetModel,
126
+ delay: calculatedDelay,
127
+ maxAttempts: opts.maxAttempts
128
+ };
129
+ }
130
+ }
131
+ };
132
+ }
133
+
72
134
  //#endregion
73
135
  //#region src/retryables/service-overloaded.ts
74
136
  /**
@@ -100,4 +162,4 @@ function serviceOverloaded(model, options) {
100
162
  }
101
163
 
102
164
  //#endregion
103
- export { contentFilterTriggered, requestNotRetryable, requestTimeout, serviceOverloaded };
165
+ export { contentFilterTriggered, requestNotRetryable, requestTimeout, retryAfterDelay, serviceOverloaded };
@@ -1,6 +1,16 @@
1
+ //#region src/get-model-key.ts
2
+ /**
3
+ * Generate a unique key for a LanguageModelV2 instance.
4
+ */
5
+ const getModelKey = (model) => {
6
+ return `${model.provider}/${model.modelId}`;
7
+ };
8
+
9
+ //#endregion
1
10
  //#region src/utils.ts
2
11
  const isObject = (value) => typeof value === "object" && value !== null;
3
12
  const isString = (value) => typeof value === "string";
13
+ const isStreamResult = (result) => "stream" in result;
4
14
  const isGenerateResult = (result) => "content" in result;
5
15
  /**
6
16
  * Type guard to check if a retry attempt is an error attempt
@@ -24,4 +34,4 @@ const isStreamContentPart = (part) => {
24
34
  };
25
35
 
26
36
  //#endregion
27
- export { isErrorAttempt, isGenerateResult, isObject, isResultAttempt, isStreamContentPart, isString };
37
+ export { getModelKey, isErrorAttempt, isGenerateResult, isObject, isResultAttempt, isStreamContentPart, isStreamResult, isString };
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "ai-retry",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "AI SDK Retry",
5
- "packageManager": "pnpm@9.0.0",
6
5
  "main": "./dist/index.js",
7
6
  "module": "./dist/index.js",
8
7
  "types": "./dist/index.d.ts",
@@ -18,14 +17,6 @@
18
17
  "publishConfig": {
19
18
  "access": "public"
20
19
  },
21
- "scripts": {
22
- "prepublishOnly": "pnpm build",
23
- "publish:alpha": "pnpm version prerelease --preid alpha && pnpm publish --tag alpha",
24
- "build": "tsdown",
25
- "test": "vitest",
26
- "lint": "biome check . --write",
27
- "prepare": "husky"
28
- },
29
20
  "keywords": [
30
21
  "ai",
31
22
  "ai-sdk",
@@ -64,5 +55,11 @@
64
55
  "dependencies": {
65
56
  "@ai-sdk/provider": "^2.0.0",
66
57
  "@ai-sdk/provider-utils": "^3.0.9"
58
+ },
59
+ "scripts": {
60
+ "publish:alpha": "pnpm version prerelease --preid alpha && pnpm publish --tag alpha",
61
+ "build": "tsdown",
62
+ "test": "vitest",
63
+ "lint": "biome check . --write"
67
64
  }
68
- }
65
+ }