@spoosh/plugin-retry 0.2.0 → 0.3.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
@@ -27,18 +27,59 @@ useRead((api) => api("posts").GET(), { retries: 5, retryDelay: 2000 });
27
27
  useRead((api) => api("posts").GET(), { retries: false });
28
28
  ```
29
29
 
30
+ ## Retry Behavior
31
+
32
+ By default, the plugin retries on:
33
+
34
+ - **Network errors** - Always retried (cannot be disabled via `shouldRetry`)
35
+ - **Status codes** - `408`, `429`, `500`, `502`, `503`, `504`
36
+
37
+ ### Custom Retry Logic
38
+
39
+ Use the `shouldRetry` callback for custom retry conditions:
40
+
41
+ ```typescript
42
+ retryPlugin({
43
+ retries: 3,
44
+ shouldRetry: ({ status, error, attempt, maxRetries }) => {
45
+ // Only retry on 503 Service Unavailable
46
+ return status === 503;
47
+ },
48
+ });
49
+
50
+ // Per-request override
51
+ useRead((api) => api("posts").GET(), {
52
+ shouldRetry: ({ status }) => status === 429,
53
+ });
54
+ ```
55
+
56
+ > **Note:** Network errors are always retried regardless of the `shouldRetry` callback return value.
57
+
30
58
  ## Options
31
59
 
32
60
  ### Plugin Config
33
61
 
34
- | Option | Type | Default | Description |
35
- | ------------ | ----------------- | ------- | ------------------------------------------------------------ |
36
- | `retries` | `number \| false` | `3` | Number of retry attempts. Set to `false` to disable retries. |
37
- | `retryDelay` | `number` | `1000` | Delay between retries in milliseconds |
62
+ | Option | Type | Default | Description |
63
+ | ------------- | --------------------- | --------------------------------------- | ---------------------------------------------------------------- |
64
+ | `retries` | `number \| false` | `3` | Number of retry attempts. Set to `false` to disable retries. |
65
+ | `retryDelay` | `number` | `1000` | Delay between retries in milliseconds (uses exponential backoff) |
66
+ | `shouldRetry` | `ShouldRetryCallback` | Retries on 408, 429, 500, 502, 503, 504 | Custom callback to determine if a request should be retried |
38
67
 
39
68
  ### Per-Request Options
40
69
 
41
- | Option | Type | Description |
42
- | ------------ | ----------------- | ---------------------------------------- |
43
- | `retries` | `number \| false` | Override retry attempts for this request |
44
- | `retryDelay` | `number` | Override retry delay for this request |
70
+ | Option | Type | Description |
71
+ | ------------- | --------------------- | ---------------------------------------- |
72
+ | `retries` | `number \| false` | Override retry attempts for this request |
73
+ | `retryDelay` | `number` | Override retry delay for this request |
74
+ | `shouldRetry` | `ShouldRetryCallback` | Override retry logic for this request |
75
+
76
+ ### ShouldRetryContext
77
+
78
+ The `shouldRetry` callback receives a context object:
79
+
80
+ | Property | Type | Description |
81
+ | ------------ | --------- | ------------------------------------ |
82
+ | `status` | `number?` | HTTP status code from the response |
83
+ | `error` | `unknown` | The error that occurred |
84
+ | `attempt` | `number` | Current attempt number (0-indexed) |
85
+ | `maxRetries` | `number` | Maximum number of retries configured |
package/dist/index.d.mts CHANGED
@@ -1,22 +1,62 @@
1
1
  import { SpooshPlugin } from '@spoosh/core';
2
2
 
3
+ /**
4
+ * Context passed to the shouldRetry callback.
5
+ */
6
+ interface ShouldRetryContext {
7
+ /** HTTP status code from the response, if available */
8
+ status?: number;
9
+ /** The error that occurred, if any */
10
+ error: unknown;
11
+ /** Current attempt number (0-indexed) */
12
+ attempt: number;
13
+ /** Maximum number of retries configured */
14
+ maxRetries: number;
15
+ }
16
+ /**
17
+ * Callback to determine if a request should be retried.
18
+ * Network errors are always retried regardless of this callback's return value.
19
+ *
20
+ * @returns `true` to retry, `false` to stop retrying
21
+ */
22
+ type ShouldRetryCallback = (context: ShouldRetryContext) => boolean;
23
+ /** Status codes that are retried by default: 408, 429, 500, 502, 503, 504 */
24
+ declare const DEFAULT_RETRY_STATUS_CODES: readonly [408, 429, 500, 502, 503, 504];
3
25
  interface RetryPluginConfig {
4
26
  /** Number of retry attempts. Set to `false` to disable retries. Defaults to 3. */
5
27
  retries?: number | false;
6
28
  /** Delay between retries in milliseconds. Defaults to 1000. */
7
29
  retryDelay?: number;
30
+ /**
31
+ * Custom callback to determine if a request should be retried.
32
+ * Network errors are always retried regardless of this callback.
33
+ * Defaults to retrying on status codes: 408, 429, 500, 502, 503, 504.
34
+ */
35
+ shouldRetry?: ShouldRetryCallback;
8
36
  }
9
37
  interface RetryReadOptions {
10
38
  /** Number of retry attempts. Set to `false` to disable retries. Overrides plugin default. */
11
39
  retries?: number | false;
12
40
  /** Delay between retries in milliseconds. Overrides plugin default. */
13
41
  retryDelay?: number;
42
+ /**
43
+ * Custom callback to determine if a request should be retried.
44
+ * Network errors are always retried regardless of this callback.
45
+ * Overrides plugin default.
46
+ */
47
+ shouldRetry?: ShouldRetryCallback;
14
48
  }
15
49
  interface RetryWriteOptions {
16
50
  /** Number of retry attempts. Set to `false` to disable retries. Overrides plugin default. */
17
51
  retries?: number | false;
18
52
  /** Delay between retries in milliseconds. Overrides plugin default. */
19
53
  retryDelay?: number;
54
+ /**
55
+ * Custom callback to determine if a request should be retried.
56
+ * Network errors are always retried regardless of this callback.
57
+ * Overrides plugin default.
58
+ */
59
+ shouldRetry?: ShouldRetryCallback;
20
60
  }
21
61
  type RetryInfiniteReadOptions = RetryReadOptions;
22
62
  type RetryReadResult = object;
@@ -56,4 +96,4 @@ declare function retryPlugin(config?: RetryPluginConfig): SpooshPlugin<{
56
96
  writeResult: RetryWriteResult;
57
97
  }>;
58
98
 
59
- export { type RetryInfiniteReadOptions, type RetryPluginConfig, type RetryReadOptions, type RetryReadResult, type RetryWriteOptions, type RetryWriteResult, retryPlugin };
99
+ export { DEFAULT_RETRY_STATUS_CODES, type RetryInfiniteReadOptions, type RetryPluginConfig, type RetryReadOptions, type RetryReadResult, type RetryWriteOptions, type RetryWriteResult, type ShouldRetryCallback, type ShouldRetryContext, retryPlugin };
package/dist/index.d.ts CHANGED
@@ -1,22 +1,62 @@
1
1
  import { SpooshPlugin } from '@spoosh/core';
2
2
 
3
+ /**
4
+ * Context passed to the shouldRetry callback.
5
+ */
6
+ interface ShouldRetryContext {
7
+ /** HTTP status code from the response, if available */
8
+ status?: number;
9
+ /** The error that occurred, if any */
10
+ error: unknown;
11
+ /** Current attempt number (0-indexed) */
12
+ attempt: number;
13
+ /** Maximum number of retries configured */
14
+ maxRetries: number;
15
+ }
16
+ /**
17
+ * Callback to determine if a request should be retried.
18
+ * Network errors are always retried regardless of this callback's return value.
19
+ *
20
+ * @returns `true` to retry, `false` to stop retrying
21
+ */
22
+ type ShouldRetryCallback = (context: ShouldRetryContext) => boolean;
23
+ /** Status codes that are retried by default: 408, 429, 500, 502, 503, 504 */
24
+ declare const DEFAULT_RETRY_STATUS_CODES: readonly [408, 429, 500, 502, 503, 504];
3
25
  interface RetryPluginConfig {
4
26
  /** Number of retry attempts. Set to `false` to disable retries. Defaults to 3. */
5
27
  retries?: number | false;
6
28
  /** Delay between retries in milliseconds. Defaults to 1000. */
7
29
  retryDelay?: number;
30
+ /**
31
+ * Custom callback to determine if a request should be retried.
32
+ * Network errors are always retried regardless of this callback.
33
+ * Defaults to retrying on status codes: 408, 429, 500, 502, 503, 504.
34
+ */
35
+ shouldRetry?: ShouldRetryCallback;
8
36
  }
9
37
  interface RetryReadOptions {
10
38
  /** Number of retry attempts. Set to `false` to disable retries. Overrides plugin default. */
11
39
  retries?: number | false;
12
40
  /** Delay between retries in milliseconds. Overrides plugin default. */
13
41
  retryDelay?: number;
42
+ /**
43
+ * Custom callback to determine if a request should be retried.
44
+ * Network errors are always retried regardless of this callback.
45
+ * Overrides plugin default.
46
+ */
47
+ shouldRetry?: ShouldRetryCallback;
14
48
  }
15
49
  interface RetryWriteOptions {
16
50
  /** Number of retry attempts. Set to `false` to disable retries. Overrides plugin default. */
17
51
  retries?: number | false;
18
52
  /** Delay between retries in milliseconds. Overrides plugin default. */
19
53
  retryDelay?: number;
54
+ /**
55
+ * Custom callback to determine if a request should be retried.
56
+ * Network errors are always retried regardless of this callback.
57
+ * Overrides plugin default.
58
+ */
59
+ shouldRetry?: ShouldRetryCallback;
20
60
  }
21
61
  type RetryInfiniteReadOptions = RetryReadOptions;
22
62
  type RetryReadResult = object;
@@ -56,4 +96,4 @@ declare function retryPlugin(config?: RetryPluginConfig): SpooshPlugin<{
56
96
  writeResult: RetryWriteResult;
57
97
  }>;
58
98
 
59
- export { type RetryInfiniteReadOptions, type RetryPluginConfig, type RetryReadOptions, type RetryReadResult, type RetryWriteOptions, type RetryWriteResult, retryPlugin };
99
+ export { DEFAULT_RETRY_STATUS_CODES, type RetryInfiniteReadOptions, type RetryPluginConfig, type RetryReadOptions, type RetryReadResult, type RetryWriteOptions, type RetryWriteResult, type ShouldRetryCallback, type ShouldRetryContext, retryPlugin };
package/dist/index.js CHANGED
@@ -20,81 +20,101 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ DEFAULT_RETRY_STATUS_CODES: () => DEFAULT_RETRY_STATUS_CODES,
23
24
  retryPlugin: () => retryPlugin
24
25
  });
25
26
  module.exports = __toCommonJS(src_exports);
26
27
 
27
- // src/plugin.ts
28
- var import_core = require("@spoosh/core");
29
-
30
- // src/cloneObject.ts
31
- function clone(value, seen = /* @__PURE__ */ new WeakMap()) {
32
- if (value === void 0 || value === null || typeof value !== "object") {
33
- return value;
34
- }
35
- if (seen.has(value)) {
36
- return seen.get(value);
37
- }
38
- if (Array.isArray(value)) {
39
- const arr = [];
40
- seen.set(value, arr);
41
- return value.map((v) => clone(v, seen));
42
- }
43
- if (value instanceof Date) {
44
- return new Date(value.getTime());
45
- }
46
- if (value instanceof RegExp) {
47
- return new RegExp(value.source, value.flags);
48
- }
49
- if (value.constructor !== Object) {
50
- return value;
51
- }
52
- const obj = {};
53
- seen.set(value, obj);
54
- for (const key in value) {
55
- if (Object.prototype.hasOwnProperty.call(value, key)) {
56
- obj[key] = clone(value[key], seen);
57
- }
58
- }
59
- return obj;
60
- }
28
+ // src/types.ts
29
+ var DEFAULT_RETRY_STATUS_CODES = [
30
+ 408,
31
+ 429,
32
+ 500,
33
+ 502,
34
+ 503,
35
+ 504
36
+ ];
61
37
 
62
38
  // src/plugin.ts
39
+ var import_core = require("@spoosh/core");
40
+ var PLUGIN_NAME = "spoosh:retry";
63
41
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
42
+ var defaultShouldRetry = ({ status }) => {
43
+ if (status === void 0) return false;
44
+ return DEFAULT_RETRY_STATUS_CODES.includes(status);
45
+ };
64
46
  function retryPlugin(config = {}) {
65
- const { retries: defaultRetries = 3, retryDelay: defaultRetryDelay = 1e3 } = config;
47
+ const {
48
+ retries: defaultRetries = 3,
49
+ retryDelay: defaultRetryDelay = 1e3,
50
+ shouldRetry: defaultShouldRetryFn = defaultShouldRetry
51
+ } = config;
66
52
  return {
67
- name: "spoosh:retry",
53
+ name: PLUGIN_NAME,
68
54
  operations: ["read", "write", "infiniteRead"],
55
+ priority: 200,
69
56
  middleware: async (context, next) => {
57
+ const t = context.tracer?.(PLUGIN_NAME);
70
58
  const pluginOptions = context.pluginOptions;
71
59
  const retriesConfig = pluginOptions?.retries ?? defaultRetries;
72
60
  const retryDelayConfig = pluginOptions?.retryDelay ?? defaultRetryDelay;
61
+ const shouldRetryFn = pluginOptions?.shouldRetry ?? defaultShouldRetryFn;
73
62
  const maxRetries = retriesConfig === false ? 0 : retriesConfig;
74
63
  if (!maxRetries || maxRetries < 0) {
64
+ t?.skip("Disabled");
75
65
  return next();
76
66
  }
77
67
  const originalRequest = {
78
- headers: clone(context.request.headers),
79
- params: clone(context.request.params),
80
- body: clone(context.request.body)
68
+ headers: (0, import_core.clone)(context.request.headers),
69
+ params: (0, import_core.clone)(context.request.params),
70
+ body: (0, import_core.clone)(context.request.body)
81
71
  };
82
72
  let res;
83
73
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
84
74
  if (attempt > 0) {
85
- context.request.headers = clone(originalRequest.headers);
86
- context.request.params = clone(originalRequest.params);
87
- context.request.body = clone(originalRequest.body);
75
+ context.request.headers = (0, import_core.clone)(originalRequest.headers);
76
+ context.request.params = (0, import_core.clone)(originalRequest.params);
77
+ context.request.body = (0, import_core.clone)(originalRequest.body);
78
+ t?.log(`Retry ${attempt}/${maxRetries}`, { color: "warning" });
88
79
  }
89
80
  res = await next();
90
81
  if ((0, import_core.isAbortError)(res.error)) {
82
+ t?.log("Aborted", { color: "muted" });
91
83
  return res;
92
84
  }
93
- if ((0, import_core.isNetworkError)(res.error) && attempt < maxRetries) {
85
+ const isLastAttempt = attempt >= maxRetries;
86
+ if ((0, import_core.isNetworkError)(res.error)) {
87
+ if (isLastAttempt) {
88
+ t?.log("Max retries reached (network error)", { color: "error" });
89
+ return res;
90
+ }
94
91
  const delayMs = retryDelayConfig * Math.pow(2, attempt);
95
92
  await delay(delayMs);
96
93
  continue;
97
94
  }
95
+ if (res.error) {
96
+ const shouldRetryResult = shouldRetryFn({
97
+ status: res.status,
98
+ error: res.error,
99
+ attempt,
100
+ maxRetries
101
+ });
102
+ if (shouldRetryResult && !isLastAttempt) {
103
+ t?.log(`Status ${res.status} - will retry`, { color: "warning" });
104
+ const delayMs = retryDelayConfig * Math.pow(2, attempt);
105
+ await delay(delayMs);
106
+ continue;
107
+ }
108
+ if (attempt > 0) {
109
+ t?.log("Max retries reached or non-retryable error", {
110
+ color: "error"
111
+ });
112
+ }
113
+ return res;
114
+ }
115
+ if (attempt > 0) {
116
+ t?.log("Retry succeeded", { color: "success" });
117
+ }
98
118
  return res;
99
119
  }
100
120
  return res;
package/dist/index.mjs CHANGED
@@ -1,51 +1,40 @@
1
- // src/plugin.ts
2
- import { isNetworkError, isAbortError } from "@spoosh/core";
3
-
4
- // src/cloneObject.ts
5
- function clone(value, seen = /* @__PURE__ */ new WeakMap()) {
6
- if (value === void 0 || value === null || typeof value !== "object") {
7
- return value;
8
- }
9
- if (seen.has(value)) {
10
- return seen.get(value);
11
- }
12
- if (Array.isArray(value)) {
13
- const arr = [];
14
- seen.set(value, arr);
15
- return value.map((v) => clone(v, seen));
16
- }
17
- if (value instanceof Date) {
18
- return new Date(value.getTime());
19
- }
20
- if (value instanceof RegExp) {
21
- return new RegExp(value.source, value.flags);
22
- }
23
- if (value.constructor !== Object) {
24
- return value;
25
- }
26
- const obj = {};
27
- seen.set(value, obj);
28
- for (const key in value) {
29
- if (Object.prototype.hasOwnProperty.call(value, key)) {
30
- obj[key] = clone(value[key], seen);
31
- }
32
- }
33
- return obj;
34
- }
1
+ // src/types.ts
2
+ var DEFAULT_RETRY_STATUS_CODES = [
3
+ 408,
4
+ 429,
5
+ 500,
6
+ 502,
7
+ 503,
8
+ 504
9
+ ];
35
10
 
36
11
  // src/plugin.ts
12
+ import { isNetworkError, isAbortError, clone } from "@spoosh/core";
13
+ var PLUGIN_NAME = "spoosh:retry";
37
14
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ var defaultShouldRetry = ({ status }) => {
16
+ if (status === void 0) return false;
17
+ return DEFAULT_RETRY_STATUS_CODES.includes(status);
18
+ };
38
19
  function retryPlugin(config = {}) {
39
- const { retries: defaultRetries = 3, retryDelay: defaultRetryDelay = 1e3 } = config;
20
+ const {
21
+ retries: defaultRetries = 3,
22
+ retryDelay: defaultRetryDelay = 1e3,
23
+ shouldRetry: defaultShouldRetryFn = defaultShouldRetry
24
+ } = config;
40
25
  return {
41
- name: "spoosh:retry",
26
+ name: PLUGIN_NAME,
42
27
  operations: ["read", "write", "infiniteRead"],
28
+ priority: 200,
43
29
  middleware: async (context, next) => {
30
+ const t = context.tracer?.(PLUGIN_NAME);
44
31
  const pluginOptions = context.pluginOptions;
45
32
  const retriesConfig = pluginOptions?.retries ?? defaultRetries;
46
33
  const retryDelayConfig = pluginOptions?.retryDelay ?? defaultRetryDelay;
34
+ const shouldRetryFn = pluginOptions?.shouldRetry ?? defaultShouldRetryFn;
47
35
  const maxRetries = retriesConfig === false ? 0 : retriesConfig;
48
36
  if (!maxRetries || maxRetries < 0) {
37
+ t?.skip("Disabled");
49
38
  return next();
50
39
  }
51
40
  const originalRequest = {
@@ -59,16 +48,46 @@ function retryPlugin(config = {}) {
59
48
  context.request.headers = clone(originalRequest.headers);
60
49
  context.request.params = clone(originalRequest.params);
61
50
  context.request.body = clone(originalRequest.body);
51
+ t?.log(`Retry ${attempt}/${maxRetries}`, { color: "warning" });
62
52
  }
63
53
  res = await next();
64
54
  if (isAbortError(res.error)) {
55
+ t?.log("Aborted", { color: "muted" });
65
56
  return res;
66
57
  }
67
- if (isNetworkError(res.error) && attempt < maxRetries) {
58
+ const isLastAttempt = attempt >= maxRetries;
59
+ if (isNetworkError(res.error)) {
60
+ if (isLastAttempt) {
61
+ t?.log("Max retries reached (network error)", { color: "error" });
62
+ return res;
63
+ }
68
64
  const delayMs = retryDelayConfig * Math.pow(2, attempt);
69
65
  await delay(delayMs);
70
66
  continue;
71
67
  }
68
+ if (res.error) {
69
+ const shouldRetryResult = shouldRetryFn({
70
+ status: res.status,
71
+ error: res.error,
72
+ attempt,
73
+ maxRetries
74
+ });
75
+ if (shouldRetryResult && !isLastAttempt) {
76
+ t?.log(`Status ${res.status} - will retry`, { color: "warning" });
77
+ const delayMs = retryDelayConfig * Math.pow(2, attempt);
78
+ await delay(delayMs);
79
+ continue;
80
+ }
81
+ if (attempt > 0) {
82
+ t?.log("Max retries reached or non-retryable error", {
83
+ color: "error"
84
+ });
85
+ }
86
+ return res;
87
+ }
88
+ if (attempt > 0) {
89
+ t?.log("Retry succeeded", { color: "success" });
90
+ }
72
91
  return res;
73
92
  }
74
93
  return res;
@@ -76,5 +95,6 @@ function retryPlugin(config = {}) {
76
95
  };
77
96
  }
78
97
  export {
98
+ DEFAULT_RETRY_STATUS_CODES,
79
99
  retryPlugin
80
100
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spoosh/plugin-retry",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Automatic retry plugin for Spoosh with configurable attempts and delay",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,11 +33,11 @@
33
33
  }
34
34
  },
35
35
  "peerDependencies": {
36
- "@spoosh/core": ">=0.12.1"
36
+ "@spoosh/core": ">=0.13.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@spoosh/core": "0.12.1",
40
- "@spoosh/test-utils": "0.1.8"
39
+ "@spoosh/core": "0.13.0",
40
+ "@spoosh/test-utils": "0.2.0"
41
41
  },
42
42
  "scripts": {
43
43
  "dev": "tsup --watch",