@mittwald/axios-cache-with-retry 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mittwald CM Service GmbH & Co. KG and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # @mittwald/axios-cache-with-retry
2
+
3
+ Coordinated retry, cache and in-flight dedupe for Axios — as a single adapter
4
+ rather than three interceptors that each re-run the request behind each other's
5
+ back.
6
+
7
+ Retry and caching cannot be composed out of separate interceptors: a retry is a
8
+ second dispatch, so it re-enters the interceptor chain and every layer around it
9
+ sees a request the caller never made. Combining `axios-retry` with
10
+ `axios-cache-interceptor` therefore multiplies requests instead of deduplicating
11
+ them, and which of the two even gets to see a failure depends on
12
+ `validateStatus`. [Why this exists](#why-this-exists) walks through a concrete
13
+ example.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @mittwald/axios-cache-with-retry
19
+ ```
20
+
21
+ `axios` is a peer dependency (`^1`).
22
+
23
+ ## Usage
24
+
25
+ ```ts
26
+ import axios from "axios";
27
+ import { setupAxiosRetryCache } from "@mittwald/axios-cache-with-retry";
28
+
29
+ const client = setupAxiosRetryCache(axios.create(), {
30
+ requestKey: ({ config }) => `${config.method ?? "get"}:${config.url}`,
31
+ cache: { ttl: 60_000, staleIfError: true },
32
+ retry: { retries: 3, retryOnStatus: [408, 429, 500, 502, 503, 504] },
33
+ });
34
+
35
+ await client.get("/users");
36
+ await client.retryCache.invalidate("get:/users");
37
+ ```
38
+
39
+ The retry decision is made for thrown Axios errors and for regular responses
40
+ alike, so it keeps working under `validateStatus: () => true`.
41
+
42
+ `setupAxiosRetryCache` replaces the instance's adapter and returns the same
43
+ instance, typed with an added `retryCache` property. Calling it twice on one
44
+ instance re-wraps the original adapter rather than stacking two layers.
45
+
46
+ ## Per-request options
47
+
48
+ Every request config accepts a `retryCache` field that overrides the global
49
+ options for that one request. The module augments Axios' own
50
+ `AxiosRequestConfig`, so the field is typed wherever `axios` is.
51
+
52
+ ```ts
53
+ await client.get("/users", { retryCache: { retry: { retries: 5 } } });
54
+ await client.get("/metrics", { retryCache: { cache: false } });
55
+ await client.get("/raw", { retryCache: false });
56
+ ```
57
+
58
+ | Value | Effect |
59
+ | ------------------- | ------------------------------------------------ |
60
+ | `false` | Bypasses the adapter entirely |
61
+ | `true` | Enables cache and retry with the global settings |
62
+ | `{ cache, retry }` | Merges into the global settings for this request |
63
+ | `{ dedupe: false }` | Opts this request out of in-flight deduplication |
64
+
65
+ ## Options
66
+
67
+ ### `cache`
68
+
69
+ | Option | Default | Description |
70
+ | -------------- | ---------------- | ------------------------------------------------------------ |
71
+ | `enabled` | `true` | Set to `false` to keep the cache off until a request opts in |
72
+ | `ttl` | `60000` | Lifetime of an entry in milliseconds |
73
+ | `methods` | `["get","head"]` | Methods whose responses are cached |
74
+ | `staleIfError` | `false` | Serve an expired entry once retries are exhausted |
75
+ | `shouldCache` | — | Predicate deciding whether a response is stored |
76
+
77
+ ### `retry`
78
+
79
+ | Option | Default | Description |
80
+ | --------------------- | ----------------------------------- | -------------------------------------------------------- |
81
+ | `enabled` | `true` | Set to `false` to keep retry off until a request opts in |
82
+ | `retries` | `2` | Attempts after the initial one |
83
+ | `methods` | `["get","head","options"]` | Methods that may be retried |
84
+ | `retryOnStatus` | `408, 425, 429, 500, 502, 503, 504` | Status codes that trigger a retry |
85
+ | `retryOnNetworkError` | `true` | Retry errors that never produced a response |
86
+ | `respectRetryAfter` | `true` | Honour a `Retry-After` response header |
87
+ | `delay` | exponential, capped at 30 s | Fixed milliseconds or a function |
88
+ | `shouldRetry` | — | Replaces the built-in decision entirely |
89
+
90
+ ### `requestKey`
91
+
92
+ Cache and dedupe key for a request. Defaults to method, base URL, URL, a stably
93
+ serialized `params` and — for non-`GET`/`HEAD` — a stably serialized body.
94
+ Returning `undefined` excludes the request from both caching and deduplication.
95
+
96
+ ### `storage`
97
+
98
+ Defaults to an in-memory store. Pass `createMemoryStorage({ maxEntries })` for a
99
+ bounded one, or any object implementing `RetryCacheStorage`; every method may
100
+ return a promise, so an async backend works as well.
101
+
102
+ ## Cache API
103
+
104
+ `client.retryCache` exposes `get`, `set`, `invalidate`, `invalidatePrefix` and
105
+ `clear`. `invalidatePrefix` needs a storage that implements either
106
+ `deletePrefix` or `keys` — the built-in memory storage implements both.
107
+
108
+ ## Behaviour worth knowing
109
+
110
+ - **Deduplication wraps the whole operation**, retries included: concurrent
111
+ callers with the same key wait for one shared result and each receive their
112
+ own shallow copy.
113
+ - **A cache hit short-circuits before retry**, so a cached response never
114
+ produces a request.
115
+ - **`staleIfError` only serves an expired entry after retries are exhausted**,
116
+ never instead of a retry.
117
+ - **Only responses are cached, never errors.**
118
+
119
+ ## Why this exists
120
+
121
+ Retry, cache and in-flight deduplication are three decisions about the _same_
122
+ request, but an interceptor can only see a request on its way out and a response
123
+ on its way back. It cannot wrap the dispatch itself — and a retry is by
124
+ definition a second dispatch. So an interceptor-based retry has to re-send the
125
+ config, which means re-entering the interceptor chain, which means every other
126
+ layer sees a request the caller never made. Stacking the layers is the only
127
+ composition the interceptor API offers, and both stacking orders are wrong.
128
+
129
+ The popular packages each solve one third of the problem and sit in a position
130
+ that collides with the others:
131
+
132
+ - **`axios-retry`** and **`retry-axios`** hook the response (error) path and
133
+ retry by re-dispatching the request config.
134
+ - **`axios-cache-interceptor`** caches _and_ deduplicates concurrent requests
135
+ from a request/response interceptor pair, with its own in-flight bookkeeping
136
+ keyed per request.
137
+ - **`axios-cache-adapter`** (unmaintained) and **`axios-extensions`**
138
+ (`cacheAdapterEnhancer`, `throttleAdapterEnhancer`) take the adapter position
139
+ instead — so they cannot coexist with each other, or with anything else that
140
+ wants to own the adapter.
141
+
142
+ ### Where it snags
143
+
144
+ Three components ask for `GET /users` at the same time. The endpoint answers
145
+ `503` twice and then `200`. Retry is configured for two extra attempts, and the
146
+ cache layer deduplicates in-flight requests. One logical request, one expected
147
+ outcome: three responses out of three network calls.
148
+
149
+ **Deduplication inside the retry** (cache layer closest to the request): dedupe
150
+ collapses the three callers onto one dispatch, that dispatch fails with `503`,
151
+ and the rejection fans back out to all three callers — each of which then runs
152
+ its _own_ retry handler, because each call walks the interceptor chain
153
+ separately. Three retry loops instead of one, up to nine network calls for one
154
+ logical `GET`, and whichever loop happens to finish first decides what ends up
155
+ in the cache while the others are still retrying.
156
+
157
+ **Retry inside the deduplication** (retry layer closest to the request): every
158
+ re-dispatch looks like a brand-new request to the cache layer above it, while
159
+ that layer's in-flight entry for the first attempt is still open and still
160
+ waiting to be settled by an attempt that has already been abandoned. Depending
161
+ on how the cache keys its pending state, the second attempt either registers
162
+ alongside the first — so the entry that gets stored is not the response the
163
+ caller received — or waits on a promise the retry loop is never going to fulfil.
164
+
165
+ **And the two layers disagree about what a failure is.** Retry lives on the
166
+ error path, the cache lives on the success path, and which one a `503` takes is
167
+ the caller's `validateStatus` setting. Accept non-2xx responses and the retry
168
+ handler is never invoked at all, while the cache stores the `503` and serves it
169
+ for the rest of its TTL.
170
+
171
+ ### What this package does instead
172
+
173
+ All three concerns live in a single adapter, the one place that _does_ wrap the
174
+ dispatch. One request key is resolved once, and one shared in-flight promise
175
+ covers the entire operation: the cache lookup, the retry loop, and the cache
176
+ write after the final attempt. Concurrent callers wait for that one result
177
+ instead of starting their own loops, retries never re-enter the interceptor
178
+ chain, and the retry decision is made for returned responses and thrown errors
179
+ alike — so `validateStatus` stops being part of the retry semantics. The
180
+ guarantees that follow from it are listed under
181
+ [Behaviour worth knowing](#behaviour-worth-knowing) above.
182
+
183
+ ## License
184
+
185
+ MIT
@@ -0,0 +1,2 @@
1
+ export { setupAxiosRetryCache } from "./setup.js";
2
+ export { createMemoryStorage, MemoryRetryCacheStorage } from "./storage.js";
@@ -0,0 +1,58 @@
1
+ export async function resolveRequestKey(key, config) {
2
+ if (typeof key === "function") {
3
+ return key({ config });
4
+ }
5
+ return defaultRequestKey(config);
6
+ }
7
+ export function defaultRequestKey(config) {
8
+ const method = (config.method ?? "get").toLowerCase();
9
+ const baseURL = config.baseURL ?? "";
10
+ const url = config.url ?? "";
11
+ const params = stableSerialize(config.params);
12
+ const data = method === "get" || method === "head" ? "" : stableSerialize(config.data);
13
+ if (!url) {
14
+ return undefined;
15
+ }
16
+ return [method, baseURL, url, params, data].join(" ");
17
+ }
18
+ export function stableSerialize(value) {
19
+ if (value === undefined || value === null || value === "") {
20
+ return "";
21
+ }
22
+ if (typeof value === "string") {
23
+ return value;
24
+ }
25
+ if (isURLSearchParams(value)) {
26
+ return serializeURLSearchParams(value);
27
+ }
28
+ return JSON.stringify(sortValue(value));
29
+ }
30
+ function sortValue(value) {
31
+ if (Array.isArray(value)) {
32
+ return value.map(sortValue);
33
+ }
34
+ if (!isPlainRecord(value)) {
35
+ return value;
36
+ }
37
+ return Object.keys(value)
38
+ .sort()
39
+ .reduce((accumulator, key) => {
40
+ accumulator[key] = sortValue(value[key]);
41
+ return accumulator;
42
+ }, {});
43
+ }
44
+ function isPlainRecord(value) {
45
+ return (typeof value === "object" && value !== null && value.constructor === Object);
46
+ }
47
+ function isURLSearchParams(value) {
48
+ return (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams);
49
+ }
50
+ function serializeURLSearchParams(params) {
51
+ return JSON.stringify(Array.from(params.entries()).sort(([leftKey, leftValue], [rightKey, rightValue]) => {
52
+ const keyComparison = leftKey.localeCompare(rightKey);
53
+ if (keyComparison !== 0) {
54
+ return keyComparison;
55
+ }
56
+ return leftValue.localeCompare(rightValue);
57
+ }));
58
+ }
@@ -0,0 +1,40 @@
1
+ export function snapshotResponse(response) {
2
+ return {
3
+ data: response.data,
4
+ status: response.status,
5
+ statusText: response.statusText,
6
+ headers: normalizeHeaders(response.headers),
7
+ };
8
+ }
9
+ export function responseFromCache(entry, config) {
10
+ return {
11
+ data: entry.response.data,
12
+ status: entry.response.status,
13
+ statusText: entry.response.statusText,
14
+ headers: { ...entry.response.headers },
15
+ config,
16
+ request: undefined,
17
+ };
18
+ }
19
+ export function cloneResponse(response) {
20
+ return {
21
+ ...response,
22
+ headers: { ...response.headers },
23
+ config: { ...response.config },
24
+ };
25
+ }
26
+ function normalizeHeaders(headers) {
27
+ if (!headers || typeof headers !== "object") {
28
+ return {};
29
+ }
30
+ const output = {};
31
+ for (const [key, value] of Object.entries(headers)) {
32
+ if (value === undefined || value === null) {
33
+ continue;
34
+ }
35
+ output[key.toLowerCase()] = Array.isArray(value)
36
+ ? value.join(", ")
37
+ : String(value);
38
+ }
39
+ return output;
40
+ }
@@ -0,0 +1,66 @@
1
+ export const DEFAULT_RETRY_STATUS = [408, 425, 429, 500, 502, 503, 504];
2
+ export async function shouldRetry(context, retry) {
3
+ if (context.attempt > retry.retries) {
4
+ return false;
5
+ }
6
+ if (retry.shouldRetry) {
7
+ return retry.shouldRetry(context);
8
+ }
9
+ const method = (context.config.method ?? "get").toLowerCase();
10
+ const allowedMethods = retry.methods?.map((value) => value.toLowerCase());
11
+ if (allowedMethods && !allowedMethods.includes(method)) {
12
+ return false;
13
+ }
14
+ if (context.response) {
15
+ return (retry.retryOnStatus ?? DEFAULT_RETRY_STATUS).includes(context.response.status);
16
+ }
17
+ return (retry.retryOnNetworkError !== false &&
18
+ isRetryableNetworkError(context.error));
19
+ }
20
+ export async function retryDelay(context, retry) {
21
+ const retryAfter = retry.respectRetryAfter
22
+ ? parseRetryAfter(context.response?.headers?.["retry-after"])
23
+ : undefined;
24
+ if (retryAfter !== undefined) {
25
+ return retryAfter;
26
+ }
27
+ if (typeof retry.delay === "number") {
28
+ return retry.delay;
29
+ }
30
+ if (typeof retry.delay === "function") {
31
+ return retry.delay(context);
32
+ }
33
+ return Math.min(100 * 2 ** Math.max(context.attempt - 1, 0), 30_000);
34
+ }
35
+ export function sleep(ms) {
36
+ if (ms <= 0) {
37
+ return Promise.resolve();
38
+ }
39
+ return new Promise((resolve) => {
40
+ setTimeout(resolve, ms);
41
+ });
42
+ }
43
+ function isRetryableNetworkError(error) {
44
+ if (!error) {
45
+ return false;
46
+ }
47
+ const maybeAxiosError = error;
48
+ if (maybeAxiosError.response) {
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ function parseRetryAfter(value) {
54
+ if (typeof value !== "string") {
55
+ return undefined;
56
+ }
57
+ const seconds = Number(value);
58
+ if (Number.isFinite(seconds)) {
59
+ return Math.max(0, seconds * 1000);
60
+ }
61
+ const timestamp = Date.parse(value);
62
+ if (Number.isNaN(timestamp)) {
63
+ return undefined;
64
+ }
65
+ return Math.max(0, timestamp - Date.now());
66
+ }
@@ -0,0 +1,253 @@
1
+ import axios from "axios";
2
+ import { resolveRequestKey } from "./key.js";
3
+ import { cloneResponse, responseFromCache, snapshotResponse, } from "./response.js";
4
+ import { DEFAULT_RETRY_STATUS, retryDelay, shouldRetry, sleep, } from "./retry.js";
5
+ import { createMemoryStorage, deletePrefix } from "./storage.js";
6
+ const DEFAULT_CACHE = {
7
+ enabled: true,
8
+ ttl: 60_000,
9
+ methods: ["get", "head"],
10
+ staleIfError: false,
11
+ };
12
+ const DEFAULT_RETRY = {
13
+ enabled: true,
14
+ retries: 2,
15
+ methods: ["get", "head", "options"],
16
+ retryOnStatus: DEFAULT_RETRY_STATUS,
17
+ retryOnNetworkError: true,
18
+ respectRetryAfter: true,
19
+ };
20
+ const installedAdapters = new WeakMap();
21
+ export function setupAxiosRetryCache(instance, options = {}) {
22
+ const storage = options.storage ?? createMemoryStorage();
23
+ const originalAdapter = installedAdapters.get(instance) ??
24
+ axios.getAdapter(instance.defaults.adapter);
25
+ const inflight = new Map();
26
+ installedAdapters.set(instance, originalAdapter);
27
+ instance.defaults.adapter = async (config) => {
28
+ const requestOptions = config.retryCache;
29
+ if (requestOptions === false) {
30
+ return originalAdapter(config);
31
+ }
32
+ const effective = resolveEffectiveOptions(options, requestOptions);
33
+ const method = (config.method ?? "get").toLowerCase();
34
+ const cacheEnabled = Boolean(effective.cache && methodAllowed(method, effective.cache.methods));
35
+ const retryEnabled = Boolean(effective.retry);
36
+ const dedupeEnabled = effective.dedupe !== false;
37
+ const requestKey = await resolveRequestKey(options.requestKey, config);
38
+ const cacheKey = requestKey;
39
+ const dedupeKey = requestKey;
40
+ if (!cacheEnabled && !retryEnabled) {
41
+ return originalAdapter(config);
42
+ }
43
+ if (cacheEnabled && cacheKey) {
44
+ const cached = await storage.get(cacheKey);
45
+ if (cached && isFresh(cached)) {
46
+ return responseFromCache(cached, config);
47
+ }
48
+ }
49
+ const operation = () => runOperation({
50
+ adapter: originalAdapter,
51
+ config,
52
+ cacheKey,
53
+ cacheEnabled,
54
+ retry: effective.retry,
55
+ cache: effective.cache,
56
+ staleEntry: cacheEnabled && cacheKey ? storage.get(cacheKey) : undefined,
57
+ storage,
58
+ });
59
+ if (dedupeEnabled && dedupeKey) {
60
+ const existing = inflight.get(dedupeKey);
61
+ if (existing) {
62
+ return cloneResponse(await existing);
63
+ }
64
+ const promise = operation().finally(() => {
65
+ inflight.delete(dedupeKey);
66
+ });
67
+ inflight.set(dedupeKey, promise);
68
+ return cloneResponse(await promise);
69
+ }
70
+ return operation();
71
+ };
72
+ const client = instance;
73
+ client.retryCache = {
74
+ async get(key) {
75
+ return storage.get(key);
76
+ },
77
+ async set(key, response, setOptions = {}) {
78
+ const now = Date.now();
79
+ await storage.set(key, {
80
+ key,
81
+ createdAt: now,
82
+ expiresAt: now + (setOptions.ttl ?? normalizeCacheOptions(options.cache).ttl),
83
+ response: snapshotResponse(response),
84
+ });
85
+ },
86
+ async invalidate(key) {
87
+ return storage.delete(key);
88
+ },
89
+ async invalidatePrefix(prefix) {
90
+ return deletePrefix(storage, prefix);
91
+ },
92
+ async clear() {
93
+ await storage.clear();
94
+ },
95
+ };
96
+ return client;
97
+ }
98
+ async function runOperation(input) {
99
+ const staleEntry = await input.staleEntry;
100
+ let attempt = 1;
101
+ let lastError;
102
+ while (true) {
103
+ try {
104
+ const response = await input.adapter(input.config);
105
+ const retryOptions = input.retry;
106
+ const retry = retryOptions
107
+ ? await shouldRetry({
108
+ attempt,
109
+ retries: retryOptions.retries,
110
+ config: input.config,
111
+ response,
112
+ }, retryOptions)
113
+ : false;
114
+ if (retry && retryOptions) {
115
+ await sleep(await retryDelay({
116
+ attempt,
117
+ retries: retryOptions.retries,
118
+ config: input.config,
119
+ response,
120
+ }, retryOptions));
121
+ attempt += 1;
122
+ continue;
123
+ }
124
+ if (input.cacheEnabled &&
125
+ input.cacheKey &&
126
+ input.cache &&
127
+ (await shouldCache(input.cacheKey, input.config, response, input.cache))) {
128
+ const now = Date.now();
129
+ await input.storage?.set(input.cacheKey, {
130
+ key: input.cacheKey,
131
+ createdAt: now,
132
+ expiresAt: now + input.cache.ttl,
133
+ response: snapshotResponse(response),
134
+ });
135
+ }
136
+ return response;
137
+ }
138
+ catch (error) {
139
+ lastError = error;
140
+ const axiosError = error;
141
+ const errorResponse = axiosError?.response;
142
+ const retryOptions = input.retry;
143
+ const retry = retryOptions
144
+ ? await shouldRetry({
145
+ attempt,
146
+ retries: retryOptions.retries,
147
+ config: input.config,
148
+ response: errorResponse,
149
+ error: error instanceof Error ? error : undefined,
150
+ }, retryOptions)
151
+ : false;
152
+ if (retry && retryOptions) {
153
+ await sleep(await retryDelay({
154
+ attempt,
155
+ retries: retryOptions.retries,
156
+ config: input.config,
157
+ response: errorResponse,
158
+ error: error instanceof Error ? error : undefined,
159
+ }, retryOptions));
160
+ attempt += 1;
161
+ continue;
162
+ }
163
+ if (input.cache &&
164
+ input.cache.staleIfError &&
165
+ staleEntry &&
166
+ !isFresh(staleEntry)) {
167
+ return responseFromCache(staleEntry, input.config);
168
+ }
169
+ throw lastError;
170
+ }
171
+ }
172
+ }
173
+ function resolveEffectiveOptions(globalOptions, requestOptions) {
174
+ const requestOverrides = requestOptions === true
175
+ ? { cache: true, retry: true }
176
+ : typeof requestOptions === "object"
177
+ ? requestOptions
178
+ : undefined;
179
+ const cache = resolveCacheOptions(globalOptions.cache, requestOverrides?.cache);
180
+ const retry = resolveRetryOptions(globalOptions.retry, requestOverrides?.retry);
181
+ return {
182
+ cache,
183
+ retry,
184
+ dedupe: requestOverrides?.dedupe ?? globalOptions.dedupe ?? true,
185
+ };
186
+ }
187
+ function resolveCacheOptions(globalCache, requestCache) {
188
+ if (requestCache === false) {
189
+ return false;
190
+ }
191
+ if (requestCache === true) {
192
+ return {
193
+ ...normalizeCacheOptions(globalCache),
194
+ enabled: true,
195
+ };
196
+ }
197
+ if (globalCache === false &&
198
+ (typeof requestCache !== "object" || requestCache.enabled !== true)) {
199
+ return false;
200
+ }
201
+ const cache = {
202
+ ...normalizeCacheOptions(globalCache),
203
+ ...definedOptions(requestCache),
204
+ };
205
+ return cache.enabled === false ? false : cache;
206
+ }
207
+ function resolveRetryOptions(globalRetry, requestRetry) {
208
+ if (requestRetry === false) {
209
+ return false;
210
+ }
211
+ if (requestRetry === true) {
212
+ return {
213
+ ...normalizeRetryOptions(globalRetry),
214
+ enabled: true,
215
+ };
216
+ }
217
+ if (globalRetry === false &&
218
+ (typeof requestRetry !== "object" || requestRetry.enabled !== true)) {
219
+ return false;
220
+ }
221
+ const retry = {
222
+ ...normalizeRetryOptions(globalRetry),
223
+ ...definedOptions(requestRetry),
224
+ };
225
+ return retry.enabled === false ? false : retry;
226
+ }
227
+ function normalizeCacheOptions(cache) {
228
+ return {
229
+ ...DEFAULT_CACHE,
230
+ ...definedOptions(cache),
231
+ };
232
+ }
233
+ function normalizeRetryOptions(retry) {
234
+ return {
235
+ ...DEFAULT_RETRY,
236
+ ...definedOptions(retry),
237
+ };
238
+ }
239
+ function definedOptions(options) {
240
+ if (!options) {
241
+ return {};
242
+ }
243
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
244
+ }
245
+ function shouldCache(key, config, response, cache) {
246
+ return cache.shouldCache?.({ key, config, response }) ?? true;
247
+ }
248
+ function methodAllowed(method, methods) {
249
+ return (!methods || methods.map((value) => value.toLowerCase()).includes(method));
250
+ }
251
+ function isFresh(entry) {
252
+ return entry.expiresAt > Date.now();
253
+ }
@@ -0,0 +1,73 @@
1
+ export class MemoryRetryCacheStorage {
2
+ options;
3
+ entries = new Map();
4
+ constructor(options = {}) {
5
+ this.options = options;
6
+ }
7
+ get(key) {
8
+ return this.entries.get(key);
9
+ }
10
+ set(key, entry) {
11
+ this.entries.set(key, entry);
12
+ this.enforceMaxEntries();
13
+ }
14
+ delete(key) {
15
+ return this.entries.delete(key);
16
+ }
17
+ clear() {
18
+ this.entries.clear();
19
+ }
20
+ keys() {
21
+ return this.entries.keys();
22
+ }
23
+ deletePrefix(prefix) {
24
+ let deleted = 0;
25
+ for (const key of this.entries.keys()) {
26
+ if (key.startsWith(prefix) && this.entries.delete(key)) {
27
+ deleted += 1;
28
+ }
29
+ }
30
+ return deleted;
31
+ }
32
+ enforceMaxEntries() {
33
+ const { maxEntries } = this.options;
34
+ if (!maxEntries || this.entries.size <= maxEntries) {
35
+ return;
36
+ }
37
+ const overflow = this.entries.size - maxEntries;
38
+ const keys = this.entries.keys();
39
+ for (let index = 0; index < overflow; index += 1) {
40
+ const next = keys.next();
41
+ if (next.done) {
42
+ return;
43
+ }
44
+ this.entries.delete(next.value);
45
+ }
46
+ }
47
+ }
48
+ export function createMemoryStorage(options) {
49
+ return new MemoryRetryCacheStorage(options);
50
+ }
51
+ export async function deletePrefix(storage, prefix) {
52
+ if (storage.deletePrefix) {
53
+ return storage.deletePrefix(prefix);
54
+ }
55
+ if (!storage.keys) {
56
+ throw new Error("Storage backend does not support prefix invalidation.");
57
+ }
58
+ let deleted = 0;
59
+ const keys = await storage.keys();
60
+ for (const key of keys) {
61
+ if (!key.startsWith(prefix)) {
62
+ continue;
63
+ }
64
+ const didDelete = await storage.delete(key);
65
+ if (didDelete) {
66
+ deleted += 1;
67
+ }
68
+ }
69
+ return deleted;
70
+ }
71
+ export async function maybeAwait(value) {
72
+ return value;
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export { setupAxiosRetryCache } from "./setup.js";
2
+ export { createMemoryStorage, MemoryRetryCacheStorage } from "./storage.js";
3
+ export type { AxiosRetryCacheInstance, CacheEntry, CachedResponse, CacheOptions, RequestKeyContext, RetryCacheApi, RetryCacheOptions, RetryCacheRequestKey, RetryCacheRequestOptions, RetryCacheStorage, RetryDecisionContext, RetryDelayContext, RetryOptions, } from "./types.js";
@@ -0,0 +1,5 @@
1
+ import type { InternalAxiosRequestConfig } from "axios";
2
+ import type { RetryCacheRequestKey } from "./types.js";
3
+ export declare function resolveRequestKey(key: RetryCacheRequestKey | undefined, config: InternalAxiosRequestConfig): Promise<string | undefined>;
4
+ export declare function defaultRequestKey(config: InternalAxiosRequestConfig): string | undefined;
5
+ export declare function stableSerialize(value: unknown): string;
@@ -0,0 +1,5 @@
1
+ import type { AxiosResponse, InternalAxiosRequestConfig } from "axios";
2
+ import type { CacheEntry, CachedResponse } from "./types.js";
3
+ export declare function snapshotResponse(response: AxiosResponse): CachedResponse;
4
+ export declare function responseFromCache(entry: CacheEntry, config: InternalAxiosRequestConfig): AxiosResponse;
5
+ export declare function cloneResponse(response: AxiosResponse): AxiosResponse;
@@ -0,0 +1,5 @@
1
+ import type { RetryDecisionContext, RetryDelayContext, RetryOptions } from "./types.js";
2
+ export declare const DEFAULT_RETRY_STATUS: number[];
3
+ export declare function shouldRetry(context: RetryDecisionContext, retry: RetryOptions): Promise<boolean>;
4
+ export declare function retryDelay(context: RetryDelayContext, retry: RetryOptions): Promise<number>;
5
+ export declare function sleep(ms: number): Promise<void>;
@@ -0,0 +1,3 @@
1
+ import { type AxiosInstance } from "axios";
2
+ import type { AxiosRetryCacheInstance, RetryCacheOptions } from "./types.js";
3
+ export declare function setupAxiosRetryCache(instance: AxiosInstance, options?: RetryCacheOptions): AxiosRetryCacheInstance;
@@ -0,0 +1,19 @@
1
+ import type { Awaitable, CacheEntry, RetryCacheStorage } from "./types.js";
2
+ export interface MemoryStorageOptions {
3
+ maxEntries?: number;
4
+ }
5
+ export declare class MemoryRetryCacheStorage<T = unknown> implements RetryCacheStorage<T> {
6
+ private readonly options;
7
+ private readonly entries;
8
+ constructor(options?: MemoryStorageOptions);
9
+ get(key: string): CacheEntry<T> | undefined;
10
+ set(key: string, entry: CacheEntry<T>): void;
11
+ delete(key: string): boolean;
12
+ clear(): void;
13
+ keys(): Iterable<string>;
14
+ deletePrefix(prefix: string): number;
15
+ private enforceMaxEntries;
16
+ }
17
+ export declare function createMemoryStorage<T = unknown>(options?: MemoryStorageOptions): MemoryRetryCacheStorage<T>;
18
+ export declare function deletePrefix(storage: RetryCacheStorage, prefix: string): Promise<number>;
19
+ export declare function maybeAwait<T>(value: Awaitable<T>): Promise<T>;
@@ -0,0 +1,86 @@
1
+ import type { AxiosAdapter, AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from "axios";
2
+ export type Awaitable<T> = T | Promise<T>;
3
+ export type RetryCacheRequestKey = (context: RequestKeyContext) => Awaitable<string | undefined>;
4
+ export interface RequestKeyContext {
5
+ config: InternalAxiosRequestConfig;
6
+ }
7
+ export interface CacheOptions {
8
+ enabled?: boolean;
9
+ ttl: number;
10
+ methods?: string[];
11
+ staleIfError?: boolean;
12
+ shouldCache?: (context: CacheDecisionContext) => Awaitable<boolean>;
13
+ }
14
+ export interface CacheDecisionContext<T = unknown, D = unknown> {
15
+ key: string;
16
+ config: InternalAxiosRequestConfig<D>;
17
+ response: AxiosResponse<T, D>;
18
+ }
19
+ export interface RetryDecisionContext<T = unknown, D = unknown> {
20
+ attempt: number;
21
+ retries: number;
22
+ config: InternalAxiosRequestConfig<D>;
23
+ response?: AxiosResponse<T, D>;
24
+ error?: AxiosError<T, D> | Error;
25
+ }
26
+ export type RetryDelayContext<T = unknown, D = unknown> = RetryDecisionContext<T, D>;
27
+ export interface RetryOptions {
28
+ enabled?: boolean;
29
+ retries: number;
30
+ methods?: string[];
31
+ retryOnStatus?: number[];
32
+ retryOnNetworkError?: boolean;
33
+ respectRetryAfter?: boolean;
34
+ delay?: number | ((context: RetryDelayContext) => Awaitable<number>);
35
+ shouldRetry?: (context: RetryDecisionContext) => Awaitable<boolean>;
36
+ }
37
+ export interface RetryCacheRequestOptions {
38
+ cache?: boolean | Partial<CacheOptions>;
39
+ retry?: boolean | Partial<RetryOptions>;
40
+ dedupe?: boolean;
41
+ }
42
+ export interface RetryCacheOptions {
43
+ requestKey?: RetryCacheRequestKey;
44
+ cache?: false | Partial<CacheOptions>;
45
+ retry?: false | Partial<RetryOptions>;
46
+ dedupe?: boolean;
47
+ storage?: RetryCacheStorage;
48
+ }
49
+ export interface CachedResponse<T = unknown> {
50
+ data: T;
51
+ status: number;
52
+ statusText: string;
53
+ headers: Record<string, string>;
54
+ }
55
+ export interface CacheEntry<T = unknown> {
56
+ key: string;
57
+ createdAt: number;
58
+ expiresAt: number;
59
+ response: CachedResponse<T>;
60
+ }
61
+ export interface RetryCacheStorage<T = unknown> {
62
+ get(key: string): Awaitable<CacheEntry<T> | undefined>;
63
+ set(key: string, entry: CacheEntry<T>): Awaitable<void>;
64
+ delete(key: string): Awaitable<boolean>;
65
+ clear(): Awaitable<void>;
66
+ keys?(): Awaitable<Iterable<string>>;
67
+ deletePrefix?(prefix: string): Awaitable<number>;
68
+ }
69
+ export interface RetryCacheApi {
70
+ get(key: string): Promise<CacheEntry | undefined>;
71
+ set(key: string, response: AxiosResponse, options?: {
72
+ ttl?: number;
73
+ }): Promise<void>;
74
+ invalidate(key: string): Promise<boolean>;
75
+ invalidatePrefix(prefix: string): Promise<number>;
76
+ clear(): Promise<void>;
77
+ }
78
+ export type AxiosRetryCacheInstance = AxiosInstance & {
79
+ retryCache: RetryCacheApi;
80
+ };
81
+ export type AdapterFactory = (adapter: AxiosAdapter) => AxiosAdapter;
82
+ declare module "axios" {
83
+ interface AxiosRequestConfig<D = any> {
84
+ retryCache?: boolean | RetryCacheRequestOptions;
85
+ }
86
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@mittwald/axios-cache-with-retry",
3
+ "version": "0.1.0",
4
+ "packageManager": "pnpm@10.29.2",
5
+ "author": "Mittwald CM Service GmbH & Co. KG <opensource@mittwald.de>",
6
+ "contributors": [
7
+ "Marco Falkenberg <m.falkenberg@mittwald.de>"
8
+ ],
9
+ "type": "module",
10
+ "description": "Coordinated retry, cache and in-flight dedupe for Axios, as a single adapter",
11
+ "keywords": [
12
+ "adapter",
13
+ "axios",
14
+ "cache",
15
+ "dedupe",
16
+ "http",
17
+ "retry"
18
+ ],
19
+ "homepage": "https://github.com/mittwald/axios-cache-with-retry#readme",
20
+ "repository": "github:mittwald/axios-cache-with-retry",
21
+ "license": "MIT",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/types/index.d.ts",
25
+ "import": "./dist/esm/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "types": "./dist/types/index.d.ts",
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "scripts": {
34
+ "build": "pnpm build:clean && pnpm tsc",
35
+ "build:clean": "rimraf dist",
36
+ "format": "prettier --write '**/*.{ts,yaml,yml,json,md}'",
37
+ "test": "pnpm test:tsc && pnpm test:lint && pnpm test:build && pnpm test:vitest",
38
+ "test:build": "pnpm build",
39
+ "test:lint": "eslint . --cache",
40
+ "test:tsc": "tsc --noEmit -p tsconfig.test.json",
41
+ "test:vitest": "vitest run"
42
+ },
43
+ "devDependencies": {
44
+ "@eslint/js": "^9.39.2",
45
+ "@types/node": "^24.10.12",
46
+ "axios": "^1.19.0",
47
+ "eslint": "^9.39.2",
48
+ "eslint-config-prettier": "^10.1.8",
49
+ "eslint-plugin-import": "^2.32.0",
50
+ "eslint-plugin-prettier": "^5.5.5",
51
+ "prettier": "^3.8.1",
52
+ "prettier-plugin-jsdoc": "^1.8.0",
53
+ "prettier-plugin-pkgsort": "^0.3.0",
54
+ "prettier-plugin-sort-json": "^4.2.0",
55
+ "rimraf": "^6.1.2",
56
+ "typescript": "^5.9.3",
57
+ "typescript-eslint": "^8.54.0",
58
+ "vitest": "^4.1.10"
59
+ },
60
+ "peerDependencies": {
61
+ "axios": "^1"
62
+ }
63
+ }