Skip to content

Commit

Permalink
fix: cloneData for concurrent requests (#921)
Browse files Browse the repository at this point in the history
* code

* code

* reuse my biome config

* fix breaking change on 1.6.0

* lint
  • Loading branch information
arthurfiorette authored Oct 18, 2024
1 parent 3c06514 commit a7a4e31
Show file tree
Hide file tree
Showing 15 changed files with 154 additions and 105 deletions.
27 changes: 1 addition & 26 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,6 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off",
"noParameterAssign": "off"
},
"suspicious": {
"noExplicitAny": "off"
}
}
},
"formatter": {
"lineWidth": 100,
"indentStyle": "space"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "none"
}
},
"extends": ["@arthurfiorette/biomejs-config"],
"files": {
"ignore": [
"build/**/*",
Expand Down
16 changes: 10 additions & 6 deletions docs/src/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ In any persistent cache scenario where hitting over 77K unique keys is a possibi

<Badge text="optional" type="warning"/>

- Type: `Record<string, Deferred<CachedResponse>>`
- Default: `{}`
- Type: `Map<string, Deferred<void>>`
- Default: `new Map`

A simple object that will hold a promise for each pending request. Used to handle
concurrent requests.

You'd normally not need to change this, but it is exposed in case you need to use it as
some sort of listener of know when a request is waiting for other to finish.
You shouldn't change this property, but it is exposed in case you need to use it as some
sort of listener or know when a request is waiting for others to finish.

## headerInterpreter

Expand Down Expand Up @@ -102,7 +102,10 @@ The possible returns are:
::: details Example of a custom headerInterpreter

```ts
import { setupCache, type HeaderInterpreter } from 'axios-cache-interceptor';
import {
setupCache,
type HeaderInterpreter
} from 'axios-cache-interceptor';

const myHeaderInterpreter: HeaderInterpreter = (headers) => {
if (headers['x-my-custom-header']) {
Expand Down Expand Up @@ -186,7 +189,8 @@ setupCache(axiosInstance, { debug: console.log });

// Own logging platform.
setupCache(axiosInstance, {
debug: ({ id, msg, data }) => myLoggerExample.emit({ id, msg, data })
debug: ({ id, msg, data }) =>
myLoggerExample.emit({ id, msg, data })
});

// Disables debug. (default)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/config/request-specifics.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ and in this [StackOverflow](https://stackoverflow.com/a/62781874/14681561) answe
<Badge text="optional" type="warning"/>

- Type: `Method[]`
- Default: `["get"]`
- Default: `["get", "head"]`

Specifies which methods we should handle and cache. This is where you can enable caching
to `POST`, `PUT`, `DELETE` and other methods, as the default is only `GET`.
Expand Down
10 changes: 7 additions & 3 deletions docs/src/guide/storages.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ For long running processes, you can avoid memory leaks by using playing with the

```ts
import Axios from 'axios';
import { setupCache, buildMemoryStorage } from 'axios-cache-interceptor';
import {
setupCache,
buildMemoryStorage
} from 'axios-cache-interceptor';

setupCache(axios, {
// You don't need to to that, as it is the default option.
Expand Down Expand Up @@ -140,7 +143,8 @@ simple object to build the storage. It has 3 methods:
storage or `undefined` if not found.

- `clear() => MaybePromise<void>`:
Clears all data from storage.
Clears all data from storage. **This method isn't used by the interceptor itself**, instead, its
here for you to use it programmatically.

## Third Party Storages

Expand Down Expand Up @@ -240,7 +244,7 @@ const indexedDbStorage = buildStorage({

### Node Cache

This example implementation uses [node-cache](https://github.com/node-cache/node-cache) as a storage method. Do note
This example implementation uses [node-cache](https://github.com/node-cache/node-cache) as a storage method. Do note
that this library is somewhat old, however it appears to work at the time of writing.

```ts
Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axios-cache-interceptor",
"version": "1.6.0",
"version": "1.6.1",
"description": "Cache interceptor for axios",
"keywords": ["axios", "cache", "interceptor", "adapter", "http", "plugin", "wrapper"],
"homepage": "https://axios-cache-interceptor.js.org",
Expand All @@ -11,7 +11,6 @@
"author": "Arthur Fiorette <[email protected]>",
"sideEffects": false,
"type": "module",
"source": "./src/index.ts",
"exports": {
".": {
"require": "./dist/index.cjs",
Expand All @@ -28,20 +27,21 @@
"jsdelivr": "./dist/index.bundle.js",
"unpkg": "./dist/index.bundle.js",
"module": "./dist/index.mjs",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"scripts": {
"benchmark": "cd benchmark && pnpm start",
"build": "bash build.sh",
"docs:build": "vitepress build docs",
"docs:dev": "vitepress dev docs --port 1227",
"docs:serve": "vitepress serve docs",
"test": "c8 --reporter lcov --reporter text node --import ./test/setup.js --enable-source-maps --test test/**/*.test.ts",
"test:only": "c8 --reporter lcov --reporter text node --import ./test/setup.js --enable-source-maps --test-only",
"version": "auto-changelog -p && cp CHANGELOG.md docs/src/others/changelog.md && git add CHANGELOG.md docs/src/others/changelog.md",
"format": "biome format --write .",
"lint": "biome check .",
"lint:ci": "biome ci .",
"lint:fix": "biome check --write --unsafe .",
"lint:ci": "biome ci ."
"test": "c8 --reporter lcov --reporter text node --import ./test/setup.js --enable-source-maps --test test/**/*.test.ts",
"test:only": "c8 --reporter lcov --reporter text node --import ./test/setup.js --enable-source-maps --test-only",
"version": "auto-changelog -p && cp CHANGELOG.md docs/src/others/changelog.md && git add CHANGELOG.md docs/src/others/changelog.md"
},
"resolutions": {
"colors": "1.4.0"
Expand All @@ -52,6 +52,7 @@
"object-code": "1.3.3"
},
"devDependencies": {
"@arthurfiorette/biomejs-config": "1.0.5",
"@biomejs/biome": "1.9.4",
"@swc-node/register": "1.9.0",
"@swc/helpers": "0.5.13",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { HeaderInterpreter } from '../header/types.js';
import type { AxiosInterceptor } from '../interceptors/build.js';
import type {
AxiosStorage,
CachedResponse,
CachedStorageValue,
LoadingStorageValue,
StaleStorageValue
Expand Down Expand Up @@ -86,7 +85,7 @@ export interface CacheProperties<R = unknown, D = unknown> {
* We use `methods` in a per-request configuration setup because sometimes you have
* exceptions to the method rule.
*
* @default ['get']
* @default ['get', 'head']
* @see https://axios-cache-interceptor.js.org/config/request-specifics#cache-methods
*/
methods: Lowercase<Method>[];
Expand Down Expand Up @@ -261,10 +260,10 @@ export interface CacheInstance {
* You'd normally not need to change this, but it is exposed in case you need to use it
* as some sort of listener of know when a request is waiting for other to finish.
*
* @default { }
* @default new Map()
* @see https://axios-cache-interceptor.js.org/config#waiting
*/
waiting: Record<string, Deferred<CachedResponse>>;
waiting: Map<string, Deferred<void>>;

/**
* The function used to interpret all headers from a request and determine a time to
Expand Down
2 changes: 1 addition & 1 deletion src/cache/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function setupCache(axios: AxiosInstance, options: CacheOptions = {}): Ax
throw new Error('Use buildStorage() function');
}

axiosCache.waiting = options.waiting || {};
axiosCache.waiting = options.waiting || new Map();

axiosCache.generateKey = options.generateKey || defaultKeyGenerator;

Expand Down
35 changes: 29 additions & 6 deletions src/interceptors/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
// This checks for simultaneous access to a new key. The js event loop jumps on the
// first await statement, so the second (asynchronous call) request may have already
// started executing.
if (axios.waiting[config.id] && !overrideCache) {
if (axios.waiting.has(config.id) && !overrideCache) {
cache = (await axios.storage.get(config.id, config)) as
| CachedStorageValue
| LoadingStorageValue;
Expand All @@ -116,11 +116,12 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
}

// Create a deferred to resolve other requests for the same key when it's completed
axios.waiting[config.id] = deferred();
const def = deferred<void>();
axios.waiting.set(config.id, def);

// Adds a default reject handler to catch when the request gets aborted without
// others waiting for it.
axios.waiting[config.id]!.catch(() => undefined);
def.catch(() => undefined);

await axios.storage.set(
config.id,
Expand Down Expand Up @@ -178,7 +179,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
let cachedResponse: CachedResponse;

if (cache.state === 'loading') {
const deferred = axios.waiting[config.id];
const deferred = axios.waiting.get(config.id);

// The deferred may not exists when the process is using a persistent
// storage and cancelled in the middle of a request, this would result in
Expand All @@ -200,7 +201,28 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
}

try {
cachedResponse = await deferred;
// Deferred can't reuse the value because the user's storage might clone
// or mutate the value, so we need to ask it again.
// For example with memoryStorage + cloneData
await deferred;
const state = await axios.storage.get(config.id, config);

// This is a cache mismatch and should never happen, but in case it does,
// we need to redo the request all over again.
/* c8 ignore start */
if (!state.data) {
if (__ACI_DEV__) {
axios.debug({
id: config.id,
msg: 'Deferred resolved, but no data was found, requesting again'
});
}

return onFulfilled(config);
}
/* c8 ignore end */

cachedResponse = state.data;
} catch (err) {
if (__ACI_DEV__) {
axios.debug({
Expand All @@ -211,10 +233,11 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
}

// Hydrates any UI temporarily, if cache is available
/* c8 ignore next 3 */
/* c8 ignore start */
if (cache.data) {
await config.cache.hydrate?.(cache);
}
/* c8 ignore end */

// The deferred is rejected when the request that we are waiting rejects its cache.
// In this case, we need to redo the request all over again.
Expand Down
37 changes: 25 additions & 12 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
await axios.storage.remove(responseId, config);

// Rejects the deferred, if present
axios.waiting[responseId]?.reject();
const deferred = axios.waiting.get(responseId);

delete axios.waiting[responseId];
if (deferred) {
deferred.reject();
axios.waiting.delete(responseId);
}
};

const onFulfilled: ResponseInterceptor['onFulfilled'] = async (response) => {
Expand Down Expand Up @@ -200,12 +203,15 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
data
};

// Define this key as cache on the storage
await axios.storage.set(response.id, newCache, config);

// Resolve all other requests waiting for this response
const waiting = axios.waiting[response.id];
const waiting = axios.waiting.get(response.id);

if (waiting) {
waiting.resolve(newCache.data);
delete axios.waiting[response.id];
waiting.resolve();
axios.waiting.delete(response.id);

if (__ACI_DEV__) {
axios.debug({
Expand All @@ -215,9 +221,6 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
}
}

// Define this key as cache on the storage
await axios.storage.set(response.id, newCache, config);

if (__ACI_DEV__) {
axios.debug({
id: response.id,
Expand Down Expand Up @@ -323,10 +326,6 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
// staleIfError is the number of seconds that stale is allowed to be used
(typeof staleIfError === 'number' && cache.createdAt + staleIfError > Date.now())
) {
// Resolve all other requests waiting for this response
axios.waiting[id]?.resolve(cache.data);
delete axios.waiting[id];

// re-mark the cache as stale
await axios.storage.set(
id,
Expand All @@ -337,6 +336,20 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
},
config
);
// Resolve all other requests waiting for this response
const waiting = axios.waiting.get(id);

if (waiting) {
waiting.resolve();
axios.waiting.delete(id);

if (__ACI_DEV__) {
axios.debug({
id,
msg: 'Found waiting deferred(s) and resolved them'
});
}
}

if (__ACI_DEV__) {
axios.debug({
Expand Down
7 changes: 0 additions & 7 deletions src/storage/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,6 @@ export interface BuildStorage extends Omit<AxiosStorage, 'get'> {
key: string,
currentRequest?: CacheRequestConfig
) => MaybePromise<StorageValue | undefined>;

/**
* Deletes all values from the storage.
*
* @see https://axios-cache-interceptor.js.org/guide/storages#buildstorage
*/
clear: () => MaybePromise<void>;
}

/**
Expand Down
Loading

0 comments on commit a7a4e31

Please sign in to comment.