Skip to content

Commit

Permalink
feat(fetcher): retry middleware (#29)
Browse files Browse the repository at this point in the history
A new middleware for retrying fetch requests when `response.ok` is
`false`.  This middleware was designed to work on a per-endpoint basis.
This means that it should be added immediately after the endpoint
function.

```
import { createApi, requestMonitor, fetcher, fetchRetry } from
'saga-query';
const api = createApi();
api.use(requestMonitor());
api.use(api.routes());
api.use(fetcher());

const fetchUsers = api.get('/users', [
    function* (ctx, next) {
        // ...
        yield next();
        // ...
    },
    fetchRetry(),
])
```

It also supports a backoff function as a parameter.  The function
signature is `(attempt: number) => number`, the result of which is how
long to delay the next fetch attempt.  If a negative number is provided
then we stop retrying.
  • Loading branch information
neurosnap authored Feb 21, 2023
1 parent 0476579 commit 8ce5257
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 2 deletions.
68 changes: 67 additions & 1 deletion src/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'ava';
import nock from 'nock';
import { fetcher } from './fetch';
import { fetcher, fetchRetry } from './fetch';
import { createApi } from './api';
import { setupStore } from './util';
import { requestMonitor } from './middleware';
Expand Down Expand Up @@ -288,3 +288,69 @@ test('fetch - slug in url but payload has empty string for slug value', async (t

await delay();
});

test('fetch retry - with success - should keep retrying fetch request', async (t) => {
t.plan(2);

nock(baseUrl).get('/users').reply(400, { message: 'error' });
nock(baseUrl).get('/users').reply(400, { message: 'error' });
nock(baseUrl).get('/users').reply(400, { message: 'error' });
nock(baseUrl).get('/users').reply(400, { message: 'error' });
nock(baseUrl).get('/users').reply(200, mockUser);

const api = createApi();
api.use(requestMonitor());
api.use(api.routes());
api.use(fetcher({ baseUrl }));

const fetchUsers = api.get('/users', [
function* (ctx, next) {
ctx.cache = true;
yield next();

if (!ctx.json.ok) {
t.fail();
}

t.deepEqual(ctx.json, { ok: true, data: mockUser });
},
fetchRetry((n) => (n > 4 ? -1 : 10)),
]);

const store = setupStore(api.saga());
const action = fetchUsers();
store.dispatch(action);

await delay();

const state = store.getState();
t.deepEqual(state['@@saga-query/data'][action.payload.key], mockUser);
});

test('fetch retry - with failure - should keep retrying and then quit', async (t) => {
t.plan(1);

nock(baseUrl).get('/users').reply(400, { message: 'error' });
nock(baseUrl).get('/users').reply(400, { message: 'error' });
nock(baseUrl).get('/users').reply(400, { message: 'error' });

const api = createApi();
api.use(requestMonitor());
api.use(api.routes());
api.use(fetcher({ baseUrl }));

const fetchUsers = api.get('/users', [
function* (ctx, next) {
ctx.cache = true;
yield next();
t.deepEqual(ctx.json, { ok: false, data: { message: 'error' } });
},
fetchRetry((n) => (n > 2 ? -1 : 10)),
]);

const store = setupStore(api.saga());
const action = fetchUsers();
store.dispatch(action);

await delay();
});
85 changes: 84 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { SagaIterator } from 'redux-saga';
import { call } from 'redux-saga/effects';
import { call, delay } from 'redux-saga/effects';
import { compose } from './compose';
import { noop } from './util';
import type { FetchCtx, FetchJsonCtx, Next } from './types';

/**
* Automatically sets `content-type` to `application/json` when
* that header is not already present.
*/
export function* headersMdw<CurCtx extends FetchCtx = FetchCtx>(
ctx: CurCtx,
next: Next,
Expand Down Expand Up @@ -69,6 +74,10 @@ export function* jsonMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
yield next();
}

/*
* This middleware takes the `baseUrl` provided to `fetcher()` and combines it
* with the url from `ctx.request.url`.
*/
export function apiUrlMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
baseUrl: string = '',
) {
Expand Down Expand Up @@ -120,6 +129,10 @@ export function* payloadMdw<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
yield next();
}

/*
* This middleware makes the `fetch` http request using `ctx.request` and
* assigns the response to `ctx.response`.
*/
export function* fetchMdw<CurCtx extends FetchCtx = FetchCtx>(
ctx: CurCtx,
next: Next,
Expand All @@ -131,6 +144,76 @@ export function* fetchMdw<CurCtx extends FetchCtx = FetchCtx>(
yield next();
}

function backoffExp(attempt: number): number {
if (attempt > 5) return -1;
// 1s, 1s, 1s, 2s, 4s
return Math.max(2 ** attempt * 125, 1000);
}

/**
* This middleware will retry failed `Fetch` request if `response.ok` is `false`.
* It accepts a backoff function to determine how long to continue retrying.
* The default is an exponential backoff {@link backoffExp} where the minimum is
* 1sec between attempts and it'll reach 4s between attempts at the end with a
* max of 5 attempts.
*
* An example backoff:
* @example
* ```ts
* // Any value less than 0 will stop the retry middleware.
* // Each attempt will wait 1s
* const backoff = (attempt: number) => {
* if (attempt > 5) return -1;
* return 1000;
* }
*
* const api = createApi();
* api.use(requestMonitor());
* api.use(api.routes());
* api.use(fetcher());
*
* const fetchUsers = api.get('/users', [
* function*(ctx, next) {
* // ...
* yield next();
* },
* // fetchRetry should be after your endpoint function because
* // the retry middleware will update `ctx.json` before it reaches your middleware
* fetchRetry(backoff),
* ])
* ```
*/
export function fetchRetry<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
backoff: (attempt: number) => number = backoffExp,
) {
return function* (ctx: CurCtx, next: Next) {
yield next();

if (!ctx.response) {
return;
}

if (ctx.response.ok) {
return;
}

let attempt = 1;
let waitFor = backoff(attempt);
while (waitFor >= 1) {
yield delay(waitFor);
yield call(fetchMdw, ctx, noop);
yield call(jsonMdw, ctx, noop);

if (ctx.response.ok) {
return;
}

attempt += 1;
waitFor = backoff(attempt);
}
};
}

/**
* This middleware is a composition of other middleware required to use `window.fetch`
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} with {@link createApi}
Expand Down
1 change: 1 addition & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { prepareStore } from './store';
import { API_ACTION_PREFIX } from './constant';
import { ApiRequest, RequiredApiRequest } from '.';

export const noop = () => {};
export const isFn = (fn?: any) => fn && typeof fn === 'function';
export const isObject = (obj?: any) => typeof obj === 'object' && obj !== null;
export const createAction = (curType: string) => {
Expand Down

0 comments on commit 8ce5257

Please sign in to comment.