Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add retryExchange package (#564)

* Create retryExchange as a separate package, allows operations to be retried based on passed options.

authored by

Will Golledge and committed by
GitHub
81893771 ca39b609

+482 -2
+5 -2
.codesandbox/ci.json
··· 5 5 "packages/preact-urql", 6 6 "packages/svelte-urql", 7 7 "exchanges/graphcache", 8 + "exchanges/retry", 8 9 "exchanges/suspense" 9 10 ], 10 - "sandboxes": ["urql-issue-template-client-iui0o"], 11 + "sandboxes": [ 12 + "urql-issue-template-client-iui0o" 13 + ], 11 14 "buildCommand": "build", 12 15 "silent": true 13 - } 16 + }
+5
exchanges/retry/CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## v0.1.0 4 + 5 + **Initial Release**
+75
exchanges/retry/README.md
··· 1 + # @urql/exchange-retry (Exchange factory) 2 + 3 + `@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. 4 + 5 + The `retryExchange` is of type `Options => Exchange`. 6 + 7 + It periodically retries requests that fail due to network errors. It accepts five optional options: 8 + 9 + - `initialDelayMs` - the minimum delay between retries, defaults to 1000 10 + - `maxDelayMs` - the maximum delay that can be applied to an operation, defaults to 15000 11 + - `randomDelay` - whether to apply a random delay to protect against thundering herd, defaults to true 12 + - `maxNumberAttempts` - the maximum number of attempts to retry any given operation, defaults to Infinity 13 + - `retryIf` - optional function to apply to errors to determine whether they should be retried 14 + 15 + 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). 16 + 17 + ## Quick Start Guide 18 + 19 + First install `@urql/exchange-retry` alongside `urql`: 20 + 21 + ```sh 22 + yarn add @urql/exchange-retry 23 + # or 24 + npm install --save @urql/exchange-retry 25 + ``` 26 + 27 + You'll then need to add the `retryExchange`, that this package exposes, to your 28 + `urql` Client: 29 + 30 + ```js 31 + import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql'; 32 + import { retryExchange } from '@urql/exchange-retry'; 33 + 34 + const options = { 35 + initialDelayMs: 50, 36 + maxDelayMs: 500, 37 + randomDelay: true, 38 + maxNumberAttempts: 10, 39 + retryIf: err => err.message === '[GraphQL] Error Message A', 40 + }; 41 + 42 + const client = createClient({ 43 + url: 'http://localhost:1234/graphql', 44 + exchanges: [ 45 + dedupExchange, 46 + cacheExchange, 47 + fetchExchange, 48 + retryExchange(options), // Use the retryExchange factory to add a new exchange 49 + ], 50 + }); 51 + ``` 52 + 53 + 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. 54 + 55 + ## Usage 56 + 57 + After installing `@urql/exchange-retry` and adding it to your `urql` client, `urql` will retry operations based on the options passed to the `retryExchange`. 58 + 59 + ```js 60 + import React from 'react'; 61 + import { useQuery } from 'urql'; 62 + 63 + const LoadingIndicator = () => <h1>Loading...</h1>; 64 + 65 + const YourContent = () => { 66 + const [{ fetching, data }] = useQuery({ query: allPostsQuery }); 67 + // Unlike a normal query, if the first request resulted in an error, 68 + // the query will be automatically retried based on the `retryExchange` options 69 + return !fetching && <div>{data}</div>; 70 + }; 71 + 72 + return <YourContent />; 73 + ``` 74 + 75 + <!-- TODO?: Add code sandbox demo -->
+58
exchanges/retry/package.json
··· 1 + { 2 + "name": "@urql/exchange-retry", 3 + "version": "0.1.0", 4 + "description": "An exchange for operation retry support in urql", 5 + "sideEffects": false, 6 + "homepage": "https://formidable.com/open-source/urql/docs/", 7 + "bugs": "https://github.com/FormidableLabs/urql/issues", 8 + "license": "MIT", 9 + "repository": { 10 + "type": "git", 11 + "url": "https://github.com/FormidableLabs/urql.git", 12 + "directory": "exchanges/retry" 13 + }, 14 + "keywords": [ 15 + "urql", 16 + "graphql client", 17 + "formidablelabs", 18 + "exchanges", 19 + "react", 20 + "retry" 21 + ], 22 + "main": "dist/urql-exchange-retry.cjs.js", 23 + "module": "dist/urql-exchange-retry.esm.js", 24 + "types": "dist/types/index.d.ts", 25 + "source": "src/index.ts", 26 + "files": [ 27 + "LICENSE", 28 + "CHANGELOG.md", 29 + "README.md", 30 + "dist/" 31 + ], 32 + "scripts": { 33 + "test": "jest", 34 + "clean": "rimraf dist", 35 + "check": "tsc --noEmit", 36 + "lint": "eslint --ext=js,jsx,ts,tsx .", 37 + "build": "rollup -c ../../scripts/rollup/config.js", 38 + "prepare": "../../scripts/prepare/index.js", 39 + "prepublishOnly": "run-s clean test build" 40 + }, 41 + "jest": { 42 + "preset": "../../scripts/jest/preset" 43 + }, 44 + "devDependencies": { 45 + "@types/react": "^16.9.19", 46 + "graphql": "^14.6.0", 47 + "react": "^16.12.0", 48 + "react-dom": "^16.12.0" 49 + }, 50 + "peerDependencies": { 51 + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0", 52 + "react": ">= 16.8.0" 53 + }, 54 + "dependencies": { 55 + "@urql/core": ">=1.9.0", 56 + "wonka": "^4.0.7" 57 + } 58 + }
+1
exchanges/retry/src/index.ts
··· 1 + export { retryExchange } from './retryExchange';
+189
exchanges/retry/src/retryExchange.test.ts
··· 1 + import gql from 'graphql-tag'; 2 + 3 + import { pipe, map, makeSubject, publish, tap } from 'wonka'; 4 + 5 + import { 6 + createClient, 7 + Operation, 8 + OperationResult, 9 + ExchangeIO, 10 + } from '@urql/core'; 11 + import { retryExchange, OperationWithRetry } from './retryExchange'; 12 + 13 + beforeEach(() => { 14 + jest.useFakeTimers(); 15 + }); 16 + 17 + afterEach(() => { 18 + jest.useRealTimers(); 19 + }); 20 + 21 + const mockOptions = { 22 + initialDelayMs: 50, 23 + maxDelayMs: 500, 24 + randomDelay: true, 25 + maxNumberAttempts: 10, 26 + retryIf: () => true, 27 + }; 28 + 29 + const queryOne = gql` 30 + { 31 + author { 32 + id 33 + name 34 + } 35 + } 36 + `; 37 + 38 + const queryOneData = { 39 + __typename: 'Query', 40 + author: { 41 + __typename: 'Author', 42 + id: '123', 43 + name: 'Author', 44 + }, 45 + }; 46 + 47 + const queryOneError = { 48 + name: 'error', 49 + message: 'scary error', 50 + }; 51 + 52 + let client, op, ops$, next; 53 + beforeEach(() => { 54 + client = createClient({ url: 'http://0.0.0.0' }); 55 + op = client.createRequestOperation('query', { 56 + key: 1, 57 + query: queryOne, 58 + }); 59 + 60 + ({ source: ops$, next } = makeSubject<OperationWithRetry>()); 61 + }); 62 + 63 + it('retries if it hits an error', () => { 64 + const response = jest.fn( 65 + (forwardOp: Operation): OperationResult => { 66 + expect(forwardOp.key).toBe(op.key); 67 + return { 68 + operation: forwardOp, 69 + // @ts-ignore 70 + error: queryOneError, 71 + }; 72 + } 73 + ); 74 + 75 + const result = jest.fn(); 76 + const forward: ExchangeIO = ops$ => { 77 + return pipe(ops$, map(response)); 78 + }; 79 + 80 + const mockRetryIf = jest.fn(() => true); 81 + pipe( 82 + retryExchange({ 83 + ...mockOptions, 84 + retryIf: mockRetryIf, 85 + })({ 86 + forward, 87 + client, 88 + })(ops$), 89 + tap(result), 90 + publish 91 + ); 92 + 93 + next(op); 94 + // Once for failed results, once for successful results 95 + expect(mockRetryIf).toHaveBeenCalledTimes(2); 96 + expect(mockRetryIf).toHaveBeenCalledWith(queryOneError); 97 + 98 + jest.runAllTimers(); 99 + 100 + // max number of retries, plus original call 101 + expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts); 102 + expect(result).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts); 103 + }); 104 + 105 + it('should retry x number of times and then return the successful result', () => { 106 + const numberRetriesBeforeSuccess = 3; 107 + const response = jest.fn( 108 + (forwardOp: OperationWithRetry): OperationResult => { 109 + expect(forwardOp.key).toBe(op.key); 110 + // @ts-ignore 111 + return { 112 + operation: forwardOp, 113 + ...(forwardOp.retryCount! >= numberRetriesBeforeSuccess 114 + ? { data: queryOneData } 115 + : { error: queryOneError }), 116 + }; 117 + } 118 + ); 119 + 120 + const result = jest.fn(); 121 + const forward: ExchangeIO = ops$ => { 122 + return pipe(ops$, map(response)); 123 + }; 124 + 125 + const mockRetryIf = jest.fn(() => true); 126 + pipe( 127 + retryExchange({ 128 + ...mockOptions, 129 + retryIf: mockRetryIf, 130 + })({ 131 + forward, 132 + client, 133 + })(ops$), 134 + tap(result), 135 + publish 136 + ); 137 + 138 + next(op); 139 + jest.runAllTimers(); 140 + 141 + expect(mockRetryIf).toHaveBeenCalledTimes(numberRetriesBeforeSuccess * 2); 142 + expect(mockRetryIf).toHaveBeenCalledWith(queryOneError); 143 + 144 + // one for original source, one for retry 145 + expect(response).toHaveBeenCalledTimes(1 + numberRetriesBeforeSuccess); 146 + expect(result).toHaveBeenCalledTimes(1 + numberRetriesBeforeSuccess); 147 + }); 148 + 149 + it(`should still retry if retryIf undefined but there is a networkError`, () => { 150 + const errorWithNetworkError = { 151 + ...queryOneError, 152 + networkError: 'scary network error', 153 + }; 154 + const response = jest.fn( 155 + (forwardOp: Operation): OperationResult => { 156 + expect(forwardOp.key).toBe(op.key); 157 + return { 158 + operation: forwardOp, 159 + // @ts-ignore 160 + error: errorWithNetworkError, 161 + }; 162 + } 163 + ); 164 + 165 + const result = jest.fn(); 166 + const forward: ExchangeIO = ops$ => { 167 + return pipe(ops$, map(response)); 168 + }; 169 + 170 + pipe( 171 + retryExchange({ 172 + ...mockOptions, 173 + retryIf: undefined, 174 + })({ 175 + forward, 176 + client, 177 + })(ops$), 178 + tap(result), 179 + publish 180 + ); 181 + 182 + next(op); 183 + 184 + jest.runAllTimers(); 185 + 186 + // max number of retries, plus original call 187 + expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts); 188 + expect(result).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts); 189 + });
+137
exchanges/retry/src/retryExchange.ts
··· 1 + import { 2 + makeSubject, 3 + share, 4 + pipe, 5 + merge, 6 + filter, 7 + tap, 8 + fromValue, 9 + delay, 10 + mergeMap, 11 + takeUntil, 12 + } from 'wonka'; 13 + import { 14 + Exchange, 15 + Operation, 16 + CombinedError, 17 + OperationResult, 18 + } from '@urql/core'; 19 + import { sourceT } from 'wonka/dist/types/src/Wonka_types.gen'; 20 + 21 + interface RetryExchangeOptions { 22 + initialDelayMs?: number; 23 + maxDelayMs?: number; 24 + randomDelay?: boolean; 25 + maxNumberAttempts?: number; 26 + /** Conditionally determine whether an error should be retried */ 27 + retryIf?: (e: CombinedError) => boolean; 28 + } 29 + 30 + export interface OperationWithRetry extends Operation { 31 + retryCount?: number; 32 + } 33 + 34 + export interface OperationResultWithRetry extends OperationResult { 35 + operation: OperationWithRetry; 36 + } 37 + 38 + export const retryExchange = ({ 39 + initialDelayMs, 40 + maxDelayMs, 41 + randomDelay, 42 + maxNumberAttempts, 43 + retryIf, 44 + }: RetryExchangeOptions): Exchange => { 45 + const MIN_DELAY = initialDelayMs || 1000; 46 + const MAX_DELAY = maxDelayMs || 15000; 47 + const MAX_ATTEMPTS = maxNumberAttempts || Infinity; 48 + const RANDOM_DELAY = randomDelay || true; 49 + 50 + const networkErrorOrRetryIf = err => 51 + (retryIf && retryIf(err)) || err.networkError; 52 + 53 + return ({ forward }) => ops$ => { 54 + const sharedOps$ = pipe(ops$, share); 55 + const { source: retry$, next: nextRetryOperation } = makeSubject< 56 + OperationWithRetry 57 + >(); 58 + let maxNumberAttemptsExceeded = false; 59 + 60 + const retryWithBackoff$ = pipe( 61 + retry$, 62 + mergeMap((op: OperationWithRetry) => { 63 + const { key, context, retryCount } = op; 64 + if (retryCount && retryCount > MAX_ATTEMPTS) { 65 + maxNumberAttemptsExceeded = true; 66 + } 67 + let delayAmount = context.retryDelay || MIN_DELAY; 68 + 69 + const backoffFactor = Math.random() + 1.5; 70 + // if randomDelay is enabled and it won't exceed the max delay, apply a random 71 + // amount to the delay to avoid thundering herd problem 72 + if (RANDOM_DELAY && delayAmount * backoffFactor < MAX_DELAY) { 73 + delayAmount *= backoffFactor; 74 + } 75 + 76 + // We stop the retries if a teardown event for this operation comes in 77 + // But if this event comes through regularly we also stop the retries, since it's 78 + // basically the query retrying itself, so no backoff should be added! 79 + const teardown$ = pipe( 80 + sharedOps$, 81 + filter(op => { 82 + return ( 83 + (op.operationName === 'query' || 84 + op.operationName === 'teardown') && 85 + op.key === key 86 + ); 87 + }) 88 + ); 89 + 90 + // Add new retryDelay and retryCount to operation 91 + return pipe( 92 + fromValue({ 93 + ...op, 94 + context: { 95 + ...op.context, 96 + retryDelay: delayAmount, 97 + }, 98 + retryCount: retryCount != null ? retryCount + 1 : 1, 99 + }), 100 + filter(op => op.retryCount < MAX_ATTEMPTS), 101 + // Here's the actual delay 102 + delay(delayAmount), 103 + // Stop retry if a teardown comes in 104 + takeUntil(teardown$) 105 + ); 106 + }) 107 + ); 108 + 109 + const result$ = pipe( 110 + merge([sharedOps$, retryWithBackoff$]), 111 + forward, 112 + share 113 + ) as sourceT<OperationResultWithRetry>; 114 + 115 + const successResult$ = pipe( 116 + result$, 117 + // We let through all results that don't fit the criteria 118 + filter(res => !res.error || !networkErrorOrRetryIf(res.error)) 119 + ); 120 + 121 + const failedResult$ = pipe( 122 + result$, 123 + // Only retry if the error passes the conditional retryIf function (if passed) 124 + // or if the error contains a networkError 125 + filter(res => !!(res.error && networkErrorOrRetryIf(res.error))), 126 + // Send failed responses to be retried by calling next on the retry$ subject 127 + // Exclude operations that have been retried more than the specified max 128 + tap(op => { 129 + if (!maxNumberAttemptsExceeded) { 130 + nextRetryOperation(op.operation); 131 + } 132 + }) 133 + ); 134 + 135 + return merge([successResult$, failedResult$]); 136 + }; 137 + };
+12
exchanges/retry/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": ["src"], 4 + "compilerOptions": { 5 + "baseUrl": "./", 6 + "paths": { 7 + "urql": ["../../node_modules/urql/src"], 8 + "*-urql": ["../../node_modules/*-urql/src"], 9 + "@urql/*": ["../../node_modules/@urql/*/src"] 10 + } 11 + } 12 + }