Repository: FormidableLabs/urql
Pull request #564 (2020-03-03 16:24:29)

Add retryExchange package
<!--
  Thanks for opening a pull request! We appreciate your dedication and help!
  Before submitting your pull request, please make sure to read our CONTRIBUTING guide.

  The best contribution is always a PR! However, if you're starting to work on a large
  change, it's best to make sure to open an issue first.

  If this PR is already related to an issue, please reference it like so:
  Resolves #123
-->

## Summary

Add a new retryExchange based on the previous [implementation](https://github.com/FormidableLabs/urql/pull/481). This version adds some additional options.

It's an exchange factory of type `Options => Exchange`.

Accepted options:
- `initialDelayMs` - the minimum delay between retries, defaults to 1000
- `maxDelayMs` - the maximum delay that can be applied to an operation
- `randomDelay` - whether to apply a random delay to protect against thundering herd, defaults to true
- `maxNumberAttempts` - the maximum number of attempts to retry any given operation, defaults to Infinity
- `retryIf` - optional function to apply to errors to determine whether they should be retried

<!-- What's the motivation of this change? What does it solve? -->

## Points of note

- I've created this as an external package rather than extending core
- Can we think of any other options that could be handy?



changeset-bot[bot]: ###  💥  No Changeset

Latest commit: 3c7996d731bc8dba1b797077e6b32eb0e3be7c24

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/feat/retry-exchange?filename=.changeset/friendly-phones-hear.md&value=---%0A%22%40fake-scope%2Ffake-pkg%22%3A%20patch%0A---%0A%0AUpdate%20the%20package%20info%20above%20to%20the%20changes%20you%20want%2C%20and%20write%20your%20description%20here.%20You%20can%20add%20more%20packages%20on%20new%20lines.%0A)


JoviDeCroock: I just realised, we should probably check for `pollInterval` being present so we don't retry those
andyrichardson: Looks siccc 🔥 

Patch:
diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json
index 85ce49fe..97eb8005 100644
--- a/.codesandbox/ci.json
+++ b/.codesandbox/ci.json
@@ -5,9 +5,12 @@
     "packages/preact-urql",
     "packages/svelte-urql",
     "exchanges/graphcache",
+    "exchanges/retry",
     "exchanges/suspense"
   ],
-  "sandboxes": ["urql-issue-template-client-iui0o"],
+  "sandboxes": [
+    "urql-issue-template-client-iui0o"
+  ],
   "buildCommand": "build",
   "silent": true
-}
+}
\ No newline at end of file
diff --git a/exchanges/retry/CHANGELOG.md b/exchanges/retry/CHANGELOG.md
new file mode 100644
index 00000000..8c30f86a
--- /dev/null
+++ b/exchanges/retry/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## v0.1.0
+
+**Initial Release**
diff --git a/exchanges/retry/README.md b/exchanges/retry/README.md
new file mode 100644
index 00000000..e3190e03
--- /dev/null
+++ b/exchanges/retry/README.md
@@ -0,0 +1,75 @@
+# @urql/exchange-retry (Exchange factory)
+
+`@urql/exchange-retry` is an exchange for the [`urql`](../../README.md) GraphQL client that allows operations (queries, mutations, subscriptions) to be retried based on an `options` parameter.
+
+The `retryExchange` is of type `Options => Exchange`.
+
+It periodically retries requests that fail due to network errors. It accepts five optional options:
+
+- `initialDelayMs` - the minimum delay between retries, defaults to 1000
+- `maxDelayMs` - the maximum delay that can be applied to an operation, defaults to 15000
+- `randomDelay` - whether to apply a random delay to protect against thundering herd, defaults to true
+- `maxNumberAttempts` - the maximum number of attempts to retry any given operation, defaults to Infinity
+- `retryIf` - optional function to apply to errors to determine whether they should be retried
+
+The `retryExchange` will exponentially increase the delay from `minDelayMs` up to `maxDelayMs` with some random jitter added to avoid the [thundering herd problem](https://en.wikipedia.org/wiki/Thundering_herd_problem).
+
+## Quick Start Guide
+
+First install `@urql/exchange-retry` alongside `urql`:
+
+```sh
+yarn add @urql/exchange-retry
+# or
+npm install --save @urql/exchange-retry
+```
+
+You'll then need to add the `retryExchange`, that this package exposes, to your
+`urql` Client:
+
+```js
+import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
+import { retryExchange } from '@urql/exchange-retry';
+
+const options = {
+  initialDelayMs: 50,
+  maxDelayMs: 500,
+  randomDelay: true,
+  maxNumberAttempts: 10,
+  retryIf: err => err.message === '[GraphQL] Error Message A',
+};
+
+const client = createClient({
+  url: 'http://localhost:1234/graphql',
+  exchanges: [
+    dedupExchange,
+    cacheExchange,
+    fetchExchange,
+    retryExchange(options), // Use the retryExchange factory to add a new exchange
+  ],
+});
+```
+
+You'll likely want to place the `retryExchange` after the `fetchExchange` so that retries are only performed _after_ the operation has passed through the cache and has attempted to fetch.
+
+## Usage
+
+After installing `@urql/exchange-retry` and adding it to your `urql` client, `urql` will retry operations based on the options passed to the `retryExchange`.
+
+```js
+import React from 'react';
+import { useQuery } from 'urql';
+
+const LoadingIndicator = () => <h1>Loading...</h1>;
+
+const YourContent = () => {
+  const [{ fetching, data }] = useQuery({ query: allPostsQuery });
+  // Unlike a normal query, if the first request resulted in an error,
+  // the query will be automatically retried based on the `retryExchange` options
+  return !fetching && <div>{data}</div>;
+};
+
+return <YourContent />;
+```
+
+<!-- TODO?: Add code sandbox demo -->
diff --git a/exchanges/retry/package.json b/exchanges/retry/package.json
new file mode 100644
index 00000000..80881d54
--- /dev/null
+++ b/exchanges/retry/package.json
@@ -0,0 +1,58 @@
+{
+  "name": "@urql/exchange-retry",
+  "version": "0.1.0",
+  "description": "An exchange for operation retry support in urql",
+  "sideEffects": false,
+  "homepage": "https://formidable.com/open-source/urql/docs/",
+  "bugs": "https://github.com/FormidableLabs/urql/issues",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/FormidableLabs/urql.git",
+    "directory": "exchanges/retry"
+  },
+  "keywords": [
+    "urql",
+    "graphql client",
+    "formidablelabs",
+    "exchanges",
+    "react",
+    "retry"
+  ],
+  "main": "dist/urql-exchange-retry.cjs.js",
+  "module": "dist/urql-exchange-retry.esm.js",
+  "types": "dist/types/index.d.ts",
+  "source": "src/index.ts",
+  "files": [
+    "LICENSE",
+    "CHANGELOG.md",
+    "README.md",
+    "dist/"
+  ],
+  "scripts": {
+    "test": "jest",
+    "clean": "rimraf dist",
+    "check": "tsc --noEmit",
+    "lint": "eslint --ext=js,jsx,ts,tsx .",
+    "build": "rollup -c ../../scripts/rollup/config.js",
+    "prepare": "../../scripts/prepare/index.js",
+    "prepublishOnly": "run-s clean test build"
+  },
+  "jest": {
+    "preset": "../../scripts/jest/preset"
+  },
+  "devDependencies": {
+    "@types/react": "^16.9.19",
+    "graphql": "^14.6.0",
+    "react": "^16.12.0",
+    "react-dom": "^16.12.0"
+  },
+  "peerDependencies": {
+    "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0",
+    "react": ">= 16.8.0"
+  },
+  "dependencies": {
+    "@urql/core": ">=1.9.0",
+    "wonka": "^4.0.7"
+  }
+}
\ No newline at end of file
diff --git a/exchanges/retry/src/index.ts b/exchanges/retry/src/index.ts
new file mode 100644
index 00000000..75cc2a48
--- /dev/null
+++ b/exchanges/retry/src/index.ts
@@ -0,0 +1,1 @@
+export { retryExchange } from './retryExchange';
diff --git a/exchanges/retry/src/retryExchange.ts b/exchanges/retry/src/retryExchange.ts
new file mode 100644
index 00000000..1833a810
--- /dev/null
+++ b/exchanges/retry/src/retryExchange.ts
@@ -0,0 +1,137 @@
+import {
+  makeSubject,
+  share,
+  pipe,
+  merge,
+  filter,
+  tap,
+  fromValue,
+  delay,
+  mergeMap,
+  takeUntil,
+} from 'wonka';
+import {
+  Exchange,
+  Operation,
+  CombinedError,
+  OperationResult,
+} from '@urql/core';
+import { sourceT } from 'wonka/dist/types/src/Wonka_types.gen';
+
+interface RetryExchangeOptions {
+  initialDelayMs?: number;
+  maxDelayMs?: number;
+  randomDelay?: boolean;
+  maxNumberAttempts?: number;
+  /** Conditionally determine whether an error should be retried */
+  retryIf?: (e: CombinedError) => boolean;
+}
+
+export interface OperationWithRetry extends Operation {
+  retryCount?: number;
+}
+
+export interface OperationResultWithRetry extends OperationResult {
+  operation: OperationWithRetry;
+}
+
+export const retryExchange = ({
+  initialDelayMs,
+  maxDelayMs,
+  randomDelay,
+  maxNumberAttempts,
+  retryIf,
+}: RetryExchangeOptions): Exchange => {
+  const MIN_DELAY = initialDelayMs || 1000;
+  const MAX_DELAY = maxDelayMs || 15000;
+  const MAX_ATTEMPTS = maxNumberAttempts || Infinity;
+  const RANDOM_DELAY = randomDelay || true;
+
+  const networkErrorOrRetryIf = err =>
+    (retryIf && retryIf(err)) || err.networkError;
+
+  return ({ forward }) => ops$ => {
+    const sharedOps$ = pipe(ops$, share);
+    const { source: retry$, next: nextRetryOperation } = makeSubject<
+      OperationWithRetry
+    >();
+    let maxNumberAttemptsExceeded = false;
+
+    const retryWithBackoff$ = pipe(
+      retry$,
+      mergeMap((op: OperationWithRetry) => {
+        const { key, context, retryCount } = op;
+        if (retryCount && retryCount > MAX_ATTEMPTS) {
+          maxNumberAttemptsExceeded = true;
+        }
+        let delayAmount = context.retryDelay || MIN_DELAY;
+
+        const backoffFactor = Math.random() + 1.5;
+        // if randomDelay is enabled and it won't exceed the max delay, apply a random
+        // amount to the delay to avoid thundering herd problem
+        if (RANDOM_DELAY && delayAmount * backoffFactor < MAX_DELAY) {
+          delayAmount *= backoffFactor;
+        }
+
+        // We stop the retries if a teardown event for this operation comes in
+        // But if this event comes through regularly we also stop the retries, since it's
+        // basically the query retrying itself, so no backoff should be added!
+        const teardown$ = pipe(
+          sharedOps$,
+          filter(op => {
+            return (
+              (op.operationName === 'query' ||
+                op.operationName === 'teardown') &&
+              op.key === key
+            );
+          })
+        );
+
+        // Add new retryDelay and retryCount to operation
+        return pipe(
+          fromValue({
+            ...op,
+            context: {
+              ...op.context,
+              retryDelay: delayAmount,
+            },
+            retryCount: retryCount != null ? retryCount + 1 : 1,
+          }),
+          filter(op => op.retryCount < MAX_ATTEMPTS),
+          // Here's the actual delay
+          delay(delayAmount),
+          // Stop retry if a teardown comes in
+          takeUntil(teardown$)
+        );
+      })
+    );
+
+    const result$ = pipe(
+      merge([sharedOps$, retryWithBackoff$]),
+      forward,
+      share
+    ) as sourceT<OperationResultWithRetry>;
+
+    const successResult$ = pipe(
+      result$,
+      // We let through all results that don't fit the criteria
+      filter(res => !res.error || !networkErrorOrRetryIf(res.error))
+    );
+
+    const failedResult$ = pipe(
+      result$,
+      // Only retry if the error passes the conditional retryIf function (if passed)
+      // or if the error contains a networkError
+      filter(res => !!(res.error && networkErrorOrRetryIf(res.error))),
+      // Send failed responses to be retried by calling next on the retry$ subject
+      // Exclude operations that have been retried more than the specified max
+      tap(op => {
+        if (!maxNumberAttemptsExceeded) {
+          nextRetryOperation(op.operation);
+        }
+      })
+    );
+
+    return merge([successResult$, failedResult$]);
+  };
+};
diff --git a/exchanges/retry/tsconfig.json b/exchanges/retry/tsconfig.json
new file mode 100644
index 00000000..cd80aea8
--- /dev/null
+++ b/exchanges/retry/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}


Test patch:
diff --git a/exchanges/retry/src/retryExchange.test.ts b/exchanges/retry/src/retryExchange.test.ts
new file mode 100644
index 00000000..4c3fd59b
--- /dev/null
+++ b/exchanges/retry/src/retryExchange.test.ts
@@ -0,0 +1,189 @@
+import gql from 'graphql-tag';
+
+import { pipe, map, makeSubject, publish, tap } from 'wonka';
+
+import {
+  createClient,
+  Operation,
+  OperationResult,
+  ExchangeIO,
+} from '@urql/core';
+import { retryExchange, OperationWithRetry } from './retryExchange';
+
+beforeEach(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.useRealTimers();
+});
+
+const mockOptions = {
+  initialDelayMs: 50,
+  maxDelayMs: 500,
+  randomDelay: true,
+  maxNumberAttempts: 10,
+  retryIf: () => true,
+};
+
+const queryOne = gql`
+  {
+    author {
+      id
+      name
+    }
+  }
+`;
+
+const queryOneData = {
+  __typename: 'Query',
+  author: {
+    __typename: 'Author',
+    id: '123',
+    name: 'Author',
+  },
+};
+
+const queryOneError = {
+  name: 'error',
+  message: 'scary error',
+};
+
+let client, op, ops$, next;
+beforeEach(() => {
+  client = createClient({ url: 'http://0.0.0.0' });
+  op = client.createRequestOperation('query', {
+    key: 1,
+    query: queryOne,
+  });
+
+  ({ source: ops$, next } = makeSubject<OperationWithRetry>());
+});
+
+it('retries if it hits an error', () => {
+  const response = jest.fn(
+    (forwardOp: Operation): OperationResult => {
+      expect(forwardOp.key).toBe(op.key);
+      return {
+        operation: forwardOp,
+        // @ts-ignore
+        error: queryOneError,
+      };
+    }
+  );
+
+  const result = jest.fn();
+  const forward: ExchangeIO = ops$ => {
+    return pipe(ops$, map(response));
+  };
+
+  const mockRetryIf = jest.fn(() => true);
+  pipe(
+    retryExchange({
+      ...mockOptions,
+      retryIf: mockRetryIf,
+    })({
+      forward,
+      client,
+    })(ops$),
+    tap(result),
+    publish
+  );
+
+  next(op);
+  // Once for failed results, once for successful results
+  expect(mockRetryIf).toHaveBeenCalledTimes(2);
+  expect(mockRetryIf).toHaveBeenCalledWith(queryOneError);
+
+  jest.runAllTimers();
+
+  // max number of retries, plus original call
+  expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
+  expect(result).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
+});
+
+it('should retry x number of times and then return the successful result', () => {
+  const numberRetriesBeforeSuccess = 3;
+  const response = jest.fn(
+    (forwardOp: OperationWithRetry): OperationResult => {
+      expect(forwardOp.key).toBe(op.key);
+      // @ts-ignore
+      return {
+        operation: forwardOp,
+        ...(forwardOp.retryCount! >= numberRetriesBeforeSuccess
+          ? { data: queryOneData }
+          : { error: queryOneError }),
+      };
+    }
+  );
+
+  const result = jest.fn();
+  const forward: ExchangeIO = ops$ => {
+    return pipe(ops$, map(response));
+  };
+
+  const mockRetryIf = jest.fn(() => true);
+  pipe(
+    retryExchange({
+      ...mockOptions,
+      retryIf: mockRetryIf,
+    })({
+      forward,
+      client,
+    })(ops$),
+    tap(result),
+    publish
+  );
+
+  next(op);
+  jest.runAllTimers();
+
+  expect(mockRetryIf).toHaveBeenCalledTimes(numberRetriesBeforeSuccess * 2);
+  expect(mockRetryIf).toHaveBeenCalledWith(queryOneError);
+
+  // one for original source, one for retry
+  expect(response).toHaveBeenCalledTimes(1 + numberRetriesBeforeSuccess);
+  expect(result).toHaveBeenCalledTimes(1 + numberRetriesBeforeSuccess);
+});
+
+it(`should still retry if retryIf undefined but there is a networkError`, () => {
+  const errorWithNetworkError = {
+    ...queryOneError,
+    networkError: 'scary network error',
+  };
+  const response = jest.fn(
+    (forwardOp: Operation): OperationResult => {
+      expect(forwardOp.key).toBe(op.key);
+      return {
+        operation: forwardOp,
+        // @ts-ignore
+        error: errorWithNetworkError,
+      };
+    }
+  );
+
+  const result = jest.fn();
+  const forward: ExchangeIO = ops$ => {
+    return pipe(ops$, map(response));
+  };
+
+  pipe(
+    retryExchange({
+      ...mockOptions,
+      retryIf: undefined,
+    })({
+      forward,
+      client,
+    })(ops$),
+    tap(result),
+    publish
+  );
+
+  next(op);
+
+  jest.runAllTimers();
+
+  // max number of retries, plus original call
+  expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
+  expect(result).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
+});