Skip to content

Commit

Permalink
fix(retry): copy retry middleware to SDK and fix it
Browse files Browse the repository at this point in the history
  • Loading branch information
nikolaymatrosov committed Feb 13, 2023
1 parent c8c3af8 commit 66b45a3
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 52 deletions.
89 changes: 40 additions & 49 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"luxon": "^2.2.0",
"nice-grpc": "^1.0.6",
"nice-grpc-client-middleware-deadline": "^1.0.6",
"nice-grpc-client-middleware-retry": "^1.1.2",
"abort-controller-x": "^0.4.1",
"node-abort-controller": "^3.1.1",
"protobufjs": "^6.11.3",
"utility-types": "^3.10.0"
},
Expand Down
129 changes: 129 additions & 0 deletions src/middleware/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
delay,
rethrowAbortError,
} from 'abort-controller-x';
import {
ClientError,
ClientMiddleware,
Status,
} from 'nice-grpc';
import { AbortController } from 'node-abort-controller';

/**
* These options are added to `CallOptions` by
* `nice-grpc-client-middleware-retry`.
*/
export type RetryOptions = {
/**
* Boolean indicating whether retries are enabled.
*
* If the method is marked as idempotent in Protobuf, i.e. has
*
* option idempotency_level = IDEMPOTENT;
*
* then the default is `true`. Otherwise, the default is `false`.
*
* Method options currently work only when compiling with `ts-proto`.
*/
retry?: boolean;
/**
* Base delay between retry attempts in milliseconds.
*
* Defaults to 1000.
*
* Example: if `retryBaseDelayMs` is 100, then retries will be attempted in
* 100ms, 200ms, 400ms etc. (not counting jitter).
*/
retryBaseDelayMs?: number;
/**
* Maximum delay between attempts in milliseconds.
*
* Defaults to 15 seconds.
*
* Example: if `retryBaseDelayMs` is 1000 and `retryMaxDelayMs` is 3000, then
* retries will be attempted in 1000ms, 2000ms, 3000ms, 3000ms etc (not
* counting jitter).
*/
retryMaxDelayMs?: number;
/**
* Maximum for the total number of attempts. `Infinity` is supported.
*
* Defaults to 1, i.e. a single retry will be attempted.
*/
retryMaxAttempts?: number;
/**
* Array of retryable status codes.
*
* Default is `[UNKNOWN, RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE]`.
*/
retryableStatuses?: Status[];
/**
* Called after receiving error with retryable status code before setting
* backoff delay timer.
*
* If the error code is not retryable, or the maximum attempts exceeded, this
* function will not be called and the error will be thrown from the client
* method.
*/
onRetryableError?(error: ClientError, attempt: number, delayMs: number): void;
};

const defaultRetryableStatuses: Status[] = [
Status.UNKNOWN,
Status.RESOURCE_EXHAUSTED,
Status.INTERNAL,
Status.UNAVAILABLE,
];

/**
* Client middleware that adds automatic retries to unary calls.
*/
export const retryMiddleware: ClientMiddleware<RetryOptions> = async function* retryMiddleware(call, options) {
const { idempotencyLevel } = call.method.options ?? {};
const isIdempotent = idempotencyLevel === 'IDEMPOTENT'
|| idempotencyLevel === 'NO_SIDE_EFFECTS';

const {
retry = isIdempotent,
retryBaseDelayMs = 1000,
retryMaxDelayMs = 15_000,
retryMaxAttempts = 1,
onRetryableError,
retryableStatuses = defaultRetryableStatuses,
...restOptions
} = options;

if (call.requestStream || call.responseStream || !retry) {
return yield* call.next(call.request, restOptions);
}

const signal = options.signal ?? new AbortController().signal;

for (let attempt = 0; ; attempt++) {
try {
return yield* call.next(call.request, restOptions);
} catch (error: unknown) {
rethrowAbortError(error);

if (
attempt >= retryMaxAttempts
|| !(error instanceof ClientError)
|| !retryableStatuses.includes(error.code)
) {
throw error;
}

// https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/
const backoff = Math.min(
retryMaxDelayMs,
2 ** attempt * retryBaseDelayMs,
);
const delayMs = Math.round((backoff * (1 + Math.random())) / 2);

onRetryableError?.(error, attempt, delayMs);

// eslint-disable-next-line no-await-in-loop
await delay(signal, delayMs);
}
}
};
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
} from '@grpc/grpc-js';
import { RawClient } from 'nice-grpc';
import { DeadlineOptions } from 'nice-grpc-client-middleware-deadline';
import { RetryOptions } from 'nice-grpc-client-middleware-retry';
import { NormalizedServiceDefinition } from 'nice-grpc/lib/service-definitions';
import { RetryOptions } from './middleware/retry';

export interface TokenService {
getToken: () => Promise<string>;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/client-factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createClientFactory } from 'nice-grpc';
import { deadlineMiddleware } from 'nice-grpc-client-middleware-deadline';
import { retryMiddleware } from 'nice-grpc-client-middleware-retry';
import { retryMiddleware } from '../middleware/retry';

export const clientFactory = createClientFactory()
.use(retryMiddleware)
Expand Down

0 comments on commit 66b45a3

Please sign in to comment.