Skip to content

Commit

Permalink
feat: add staleIfError support
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Jan 18, 2022
1 parent 8273399 commit edb32bd
Show file tree
Hide file tree
Showing 13 changed files with 526 additions and 70 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"plugin:prettier/recommended"
],
"rules": {
"@typescript-eslint/await-thenable": "off"
"@typescript-eslint/await-thenable": "off",
"@typescript-eslint/restrict-template-expressions": "off"
},
"parserOptions": {
"ecmaVersion": "latest",
Expand Down
4 changes: 2 additions & 2 deletions src/cache/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ export interface AxiosCacheInstance extends CacheInstance, AxiosInstance {
};

interceptors: {
request: AxiosInterceptorManager<CacheRequestConfig<unknown, unknown>>;
response: AxiosInterceptorManager<CacheAxiosResponse<unknown, unknown>>;
request: AxiosInterceptorManager<CacheRequestConfig>;
response: AxiosInterceptorManager<CacheAxiosResponse>;
};

/** @template D The type that the request body use */
Expand Down
42 changes: 39 additions & 3 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type { Deferred } from 'fast-defer';
import type { HeadersInterpreter } from '../header/types';
import type { AxiosInterceptor } from '../interceptors/build';
import type { AxiosStorage, CachedResponse } from '../storage/types';
import type { CachePredicate, CacheUpdater, KeyGenerator } from '../util/types';
import type {
CachePredicate,
CacheUpdater,
KeyGenerator,
StaleIfErrorPredicate
} from '../util/types';
import type { CacheAxiosResponse, CacheRequestConfig } from './axios';

/**
Expand Down Expand Up @@ -76,6 +81,37 @@ export type CacheProperties<R = unknown, D = unknown> = {
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
*/
modifiedSince: Date | boolean;

/**
* Enables cache to be returned if the response comes with an error, either by invalid
* status code, network errors and etc. You can filter the type of error that should be
* stale by using a predicate function.
*
* **Note**: If this value ends up `false`, either by default or by a predicate function
* and there was an error, the request cache will be purged.
*
* **Note**: If the response is treated as error because of invalid status code *(like
* from AxiosRequestConfig#invalidateStatus)*, and this ends up `true`, the cache will
* be preserved over the "invalid" request. So, if you want to preserve the response,
* you can use this predicate:
*
* ```js
* const customPredicate = (response, cache, error) => {
* // Return false if has a response
* return !response;
* };
* ```
*
* Possible types:
*
* - `number` -> the max time (in seconds) that the cache can be reused.
* - `boolean` -> `false` disables and `true` enables with infinite time.
* - `function` -> a predicate that can return `number` or `boolean` as described above.
*
* @default false
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error
*/
staleIfError: StaleIfErrorPredicate<R, D>;
};

export interface CacheInstance {
Expand Down Expand Up @@ -107,8 +143,8 @@ export interface CacheInstance {
headerInterpreter: HeadersInterpreter;

/** The request interceptor that will be used to handle the cache. */
requestInterceptor: AxiosInterceptor<CacheRequestConfig<unknown, unknown>>;
requestInterceptor: AxiosInterceptor<CacheRequestConfig>;

/** The response interceptor that will be used to handle the cache. */
responseInterceptor: AxiosInterceptor<CacheAxiosResponse<unknown, unknown>>;
responseInterceptor: AxiosInterceptor<CacheAxiosResponse>;
}
24 changes: 11 additions & 13 deletions src/cache/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,17 @@ export function setupCache(
options.responseInterceptor || defaultResponseInterceptor(axiosCache);

// CacheRequestConfig values
axiosCache.defaults = {
...axios.defaults,
cache: {
ttl: options.ttl ?? 1000 * 60 * 5,
interpretHeader: options.interpretHeader ?? false,
methods: options.methods || ['get'],
cachePredicate: options.cachePredicate || {
statusCheck: (status) => status >= 200 && status < 400
},
etag: options.etag ?? false,
modifiedSince: options.modifiedSince ?? false,
update: options.update || {}
}
axiosCache.defaults.cache = {
ttl: options.ttl ?? 1000 * 60 * 5,
interpretHeader: options.interpretHeader ?? false,
methods: options.methods || ['get'],
cachePredicate: options.cachePredicate || {
statusCheck: (status) => status >= 200 && status < 400
},
etag: options.etag ?? false,
modifiedSince: options.modifiedSince ?? false,
staleIfError: options.staleIfError ?? false,
update: options.update || {}
};

// Apply interceptors
Expand Down
9 changes: 6 additions & 3 deletions src/interceptors/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios';
/** See {@link AxiosInterceptorManager} */
export interface AxiosInterceptor<T> {
onFulfilled?(value: T): T | Promise<T>;
onRejected?(error: unknown): unknown;

/** Returns a successful response or re-throws the error */
onRejected?(error: Record<string, unknown>): T | Promise<T>;

apply: () => void;
}

export type RequestInterceptor = AxiosInterceptor<CacheRequestConfig<unknown, unknown>>;
export type ResponseInterceptor = AxiosInterceptor<CacheAxiosResponse<unknown, unknown>>;
export type RequestInterceptor = AxiosInterceptor<CacheRequestConfig>;
export type ResponseInterceptor = AxiosInterceptor<CacheAxiosResponse>;
14 changes: 10 additions & 4 deletions src/interceptors/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ConfigWithCache,
createValidateStatus,
isMethodIn,
setRevalidationHeaders
updateStaleRequest
} from './util';

export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
Expand Down Expand Up @@ -56,11 +56,17 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {

await axios.storage.set(key, {
state: 'loading',
data: cache.data
previous: cache.state,

// Eslint complains a lot :)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
data: cache.data as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
createdAt: cache.createdAt as any
});

if (cache.state === 'stale') {
setRevalidationHeaders(cache, config as ConfigWithCache<unknown>);
updateStaleRequest(cache, config as ConfigWithCache<unknown>);
}

config.validateStatus = createValidateStatus(config.validateStatus);
Expand Down Expand Up @@ -92,7 +98,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {

// Even though the response interceptor receives this one from here,
// it has been configured to ignore cached responses = true
config.adapter = (): Promise<CacheAxiosResponse<unknown, unknown>> =>
config.adapter = (): Promise<CacheAxiosResponse> =>
Promise.resolve({
config,
data: cachedResponse.data,
Expand Down
119 changes: 97 additions & 22 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { AxiosCacheInstance } from '../cache/axios';
import type { CacheProperties } from '../cache/cache';
import type { CacheProperties } from '..';
import type {
AxiosCacheInstance,
CacheAxiosResponse,
CacheRequestConfig
} from '../cache/axios';
import type { CachedStorageValue } from '../storage/types';
import { testCachePredicate } from '../util/cache-predicate';
import { Header } from '../util/headers';
Expand All @@ -15,15 +19,12 @@ export function defaultResponseInterceptor(
*
* Also update the waiting list for this key by rejecting it.
*/
const rejectResponse = async (
{ storage, waiting }: AxiosCacheInstance,
responseId: string
) => {
const rejectResponse = async (responseId: string) => {
// Update the cache to empty to prevent infinite loading state
await storage.remove(responseId);
await axios.storage.remove(responseId);
// Reject the deferred if present
waiting[responseId]?.reject(null);
delete waiting[responseId];
axios.waiting[responseId]?.reject(null);
delete axios.waiting[responseId];
};

const onFulfilled: ResponseInterceptor['onFulfilled'] = async (response) => {
Expand All @@ -41,6 +42,7 @@ export function defaultResponseInterceptor(
return { ...response, cached: false };
}

// Request interceptor merges defaults with per request configuration
const cacheConfig = response.config.cache as CacheProperties;

const cache = await axios.storage.get(response.id);
Expand All @@ -61,13 +63,18 @@ export function defaultResponseInterceptor(
!cache.data &&
!(await testCachePredicate(response, cacheConfig.cachePredicate))
) {
await rejectResponse(axios, response.id);
await rejectResponse(response.id);
return response;
}

// avoid remnant headers from remote server to break implementation
delete response.headers[Header.XAxiosCacheEtag];
delete response.headers[Header.XAxiosCacheLastModified];
for (const header in Header) {
if (!header.startsWith('XAxiosCache')) {
continue;
}

delete response.headers[header];
}

if (cacheConfig.etag && cacheConfig.etag !== true) {
response.headers[Header.XAxiosCacheEtag] = cacheConfig.etag;
Expand All @@ -87,7 +94,7 @@ export function defaultResponseInterceptor(

// Cache should not be used
if (expirationTime === 'dont cache') {
await rejectResponse(axios, response.id);
await rejectResponse(response.id);
return response;
}

Expand All @@ -100,22 +107,24 @@ export function defaultResponseInterceptor(
ttl = await ttl(response);
}

const newCache: CachedStorageValue = {
state: 'cached',
ttl,
createdAt: Date.now(),
data
};
if (cacheConfig.staleIfError) {
response.headers[Header.XAxiosCacheStaleIfError] = String(ttl);
}

// Update other entries before updating himself
if (cacheConfig?.update) {
await updateCache(axios.storage, response, cacheConfig.update);
}

const deferred = axios.waiting[response.id];
const newCache: CachedStorageValue = {
state: 'cached',
ttl,
createdAt: Date.now(),
data
};

// Resolve all other requests waiting for this response
deferred?.resolve(newCache.data);
axios.waiting[response.id]?.resolve(newCache.data);
delete axios.waiting[response.id];

// Define this key as cache on the storage
Expand All @@ -125,8 +134,74 @@ export function defaultResponseInterceptor(
return response;
};

const onRejected: ResponseInterceptor['onRejected'] = async (error) => {
const config = error['config'] as CacheRequestConfig;

if (!config || config.cache === false || !config.id) {
throw error;
}

const cache = await axios.storage.get(config.id);
const cacheConfig = config.cache;

if (
// This will only not be loading if the interceptor broke
cache.state !== 'loading' ||
cache.previous !== 'stale'
) {
await rejectResponse(config.id);
throw error;
}

if (cacheConfig?.staleIfError) {
const staleIfError =
typeof cacheConfig.staleIfError === 'function'
? await cacheConfig.staleIfError(
error.response as CacheAxiosResponse,
cache,
error
)
: cacheConfig.staleIfError;

if (
staleIfError === true ||
// staleIfError is the number of seconds that stale is allowed to be used
(typeof staleIfError === 'number' && cache.createdAt + staleIfError > Date.now())
) {
const newCache: CachedStorageValue = {
state: 'cached',
ttl: Number(cache.data.headers[Header.XAxiosCacheStaleIfError]),
createdAt: Date.now(),
data: cache.data
};

const response: CacheAxiosResponse = {
cached: true,
config,
id: config.id,
data: cache.data?.data,
headers: cache.data?.headers,
status: cache.data.status,
statusText: cache.data.statusText
};

// Resolve all other requests waiting for this response
axios.waiting[response.id]?.resolve(newCache.data);
delete axios.waiting[response.id];

// Valid response
return response;
}
}

// Reject this response and rethrows the error
await rejectResponse(config.id);
throw error;
};

return {
onFulfilled,
apply: () => axios.interceptors.response.use(onFulfilled)
onRejected,
apply: () => axios.interceptors.response.use(onFulfilled, onRejected)
};
}
6 changes: 5 additions & 1 deletion src/interceptors/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ export type ConfigWithCache<D> = CacheRequestConfig<unknown, D> & {
cache: Partial<CacheProperties>;
};

export function setRevalidationHeaders<D>(
/**
* This function updates the cache when the request is stale. So, the next request to the
* server will be made with proper header / settings.
*/
export function updateStaleRequest<D>(
cache: StaleStorageValue,
config: ConfigWithCache<D>
): void {
Expand Down
2 changes: 2 additions & 0 deletions src/storage/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage

if (
value.data.headers &&
// Any header below allows the response to stale
(Header.ETag in value.data.headers ||
Header.LastModified in value.data.headers ||
Header.XAxiosCacheEtag in value.data.headers ||
Header.XAxiosCacheStaleIfError in value.data.headers ||
Header.XAxiosCacheLastModified in value.data.headers)
) {
const stale: StaleStorageValue = {
Expand Down
Loading

0 comments on commit edb32bd

Please sign in to comment.