-
Notifications
You must be signed in to change notification settings - Fork 21
Add support for scheduled retry with retryAfter #94
Changes from 16 commits
e676139
7e1f414
2cd04f6
9e7b028
b39cc74
916226d
55db3f0
447ba17
3a27406
7a4a032
cbc121f
45a0204
9263029
522cb32
eba4b42
96c8d3a
6afd99c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { | |
| CircuitBreakerPublicApi | ||
| } from "./circuit-breaker"; | ||
| import { operation } from "./retry"; | ||
| import { performance } from "perf_hooks"; | ||
| import * as url from "url"; | ||
| import { | ||
| ConnectionTimeoutError, | ||
|
|
@@ -84,6 +85,7 @@ export class ServiceClientOptions { | |
| public autoParseJson?: boolean; | ||
| public retryOptions?: { | ||
| retries?: number; | ||
| retryAfter?: number; | ||
| factor?: number; | ||
| minTimeout?: number; | ||
| maxTimeout?: number; | ||
|
|
@@ -97,6 +99,7 @@ export class ServiceClientOptions { | |
| }; | ||
| public circuitBreaker?: false | CircuitBreakerOptions | CircuitBreakerFactory; | ||
| public defaultRequestOptions?: Partial<ServiceClientRequestOptions>; | ||
| public dropAllRequestsAfter?: number; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -109,6 +112,7 @@ class ServiceClientStrictOptions { | |
| public autoParseJson: boolean; | ||
| public retryOptions: { | ||
| retries: number; | ||
| retryAfter: number; | ||
| factor: number; | ||
| minTimeout: number; | ||
| maxTimeout: number; | ||
|
|
@@ -121,6 +125,7 @@ class ServiceClientStrictOptions { | |
| ) => void; | ||
| }; | ||
| public defaultRequestOptions: ServiceClientRequestOptions; | ||
| public dropAllRequestsAfter: number; | ||
|
|
||
| constructor(options: ServiceClientOptions) { | ||
| if (!options.hostname) { | ||
|
|
@@ -140,6 +145,7 @@ class ServiceClientStrictOptions { | |
| minTimeout: 200, | ||
| randomize: true, | ||
| retries: 0, | ||
| retryAfter: 0, | ||
| shouldRetry() { | ||
| return true; | ||
| }, | ||
|
|
@@ -163,6 +169,8 @@ class ServiceClientStrictOptions { | |
| timeout: 2000, | ||
| ...options.defaultRequestOptions | ||
| }; | ||
|
|
||
| this.dropAllRequestsAfter = options.dropAllRequestsAfter || 0; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -586,6 +594,7 @@ export class ServiceClient { | |
|
|
||
| const { | ||
| retries, | ||
| retryAfter, | ||
| factor, | ||
| minTimeout, | ||
| maxTimeout, | ||
|
|
@@ -604,22 +613,54 @@ export class ServiceClient { | |
|
|
||
| const retryErrors: ServiceClientError[] = []; | ||
| return new Promise<ServiceClientResponse>((resolve, reject) => { | ||
| const timerInitial = performance.now(); | ||
| const breaker = this.getCircuitBreaker(params); | ||
| const retryOperation = operation(opts, (currentAttempt: number) => { | ||
| const requestsAbortCallbacks: (() => void)[] = []; | ||
| const retryOperation = operation(opts, scheduledRetry => { | ||
| breaker.run( | ||
| (success: () => void, failure: () => void) => { | ||
| if (this.options.dropAllRequestsAfter) { | ||
| const timerLeft = | ||
| this.options.dropAllRequestsAfter - | ||
| (performance.now() - timerInitial); | ||
| if (params.dropRequestAfter) { | ||
| params.dropRequestAfter = Math.min( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
| params.dropRequestAfter, | ||
| timerLeft | ||
| ); | ||
| } else { | ||
| params.dropRequestAfter = timerLeft; | ||
| } | ||
| } | ||
|
|
||
| if (retryAfter) { | ||
| params.registerAbortCallback = cb => | ||
| requestsAbortCallbacks.push(cb); | ||
| } | ||
|
|
||
| return requestWithFilters( | ||
| this, | ||
| params, | ||
| this.options.filters || [], | ||
| this.options.autoParseJson | ||
| ) | ||
| .then((result: ServiceClientResponse) => { | ||
| if (retryOperation.isResolved()) { | ||
| return; | ||
| } | ||
| retryOperation.resolved(); | ||
| success(); | ||
| result.retryErrors = retryErrors; | ||
| resolve(result); | ||
| if (retryAfter) { | ||
| requestsAbortCallbacks.forEach(cb => cb()); | ||
| requestsAbortCallbacks.length = 0; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, this is a cleanup for the happy case. Is this array of callbacks cleaned up in the sad case (all requests fail)? I'm not sure if it causes a memory leak only from the code, but I think it's worth checking.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes good catch! Will check that |
||
| } | ||
| }) | ||
| .catch((error: ServiceClientError) => { | ||
| if (retryOperation.isResolved()) { | ||
| return; | ||
| } | ||
| retryErrors.push(error); | ||
| failure(); | ||
| if (!shouldRetry(error, params)) { | ||
|
|
@@ -628,11 +669,12 @@ export class ServiceClient { | |
| ); | ||
| return; | ||
| } | ||
| if (!retryOperation.retry()) { | ||
| const currentAttempt = retryOperation.retry(); | ||
| if (!currentAttempt) { | ||
| // Wrapping error when user does not want retries would result | ||
| // in bad developer experience where you always have to unwrap it | ||
| // knowing there is only one error inside, so we do not do that. | ||
| if (retries === 0) { | ||
| if (retries === 0 || scheduledRetry) { | ||
| reject(error); | ||
| } else { | ||
| reject( | ||
|
|
@@ -654,6 +696,20 @@ export class ServiceClient { | |
| ); | ||
| }); | ||
| retryOperation.attempt(); | ||
|
|
||
| if (retryAfter) { | ||
| const retryAfterTimeout = () => | ||
| setTimeout(() => { | ||
| if (!retryOperation.isResolved()) { | ||
| const currentAttempt = retryOperation.retry(true); | ||
| if (currentAttempt) { | ||
| onRetry(currentAttempt + 1, undefined, params); | ||
| retryAfterTimeout(); | ||
| } | ||
| } | ||
| }, retryAfter); | ||
| retryAfterTimeout(); | ||
| } | ||
| }).catch((error: unknown) => { | ||
| const rawError = | ||
| error instanceof Error ? error : new Error(String(error)); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,6 +70,7 @@ export interface ServiceClientRequestOptions extends RequestOptions { | |
| * Opentracing like span interface to log events | ||
| */ | ||
| span?: Span; | ||
| registerAbortCallback?: (abortFunction: () => void) => void; | ||
| } | ||
|
|
||
| export class ServiceClientResponse { | ||
|
|
@@ -218,6 +219,28 @@ export const request = ( | |
| } | ||
|
|
||
| const requestObject = httpRequestFn(options); | ||
|
|
||
| let dropRequestAfterTimeout: NodeJS.Timer; | ||
| if (options.dropRequestAfter) { | ||
| dropRequestAfterTimeout = setTimeout(() => { | ||
| abortCallback(); | ||
| }, options.dropRequestAfter); | ||
| } | ||
|
|
||
| function abortCallback() { | ||
| clearTimeout(dropRequestAfterTimeout); | ||
| if (!hasRequestEnded) { | ||
| requestObject.abort(); | ||
| const err = new UserTimeoutError(options, timings); | ||
| logEvent(EventSource.HTTP_REQUEST, EventName.ERROR, err.message); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to mark aborted requests as timeouts when aborting because of a successful request?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logging is one thing, but we also need to make sure that this doesn't open the circuit breaker when aborting because of another successful request.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once one on the request has succeed, retryOperation will be marked as catch((error: ServiceClientError) => {
if (retryOperation.isResolved()) {
return;
}
retryErrors.push(error);
failure();
...So actually clients are not even aware of the result of this promise. There is a vicious side effect though.: this request is actually never resolved or rejected: is it a problem?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aha, so the circuit breaker command's But isn't the circuit breaker designed with an assumption that one of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, we should stop the timeout timer in the circuit breaker when aborting requests because of another successful request. Because calling We may want to mark failures in some cases though. For example, if the first request doesn't respond and the second request succeeds, it makes more sense to mark the first request as a failure.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wow very good catch! Will evaluate adding this new function
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok this approach seems to work. I have to write more tests now to cover all the different cases:
|
||
| reject(err); | ||
| } | ||
| } | ||
|
|
||
| if (options.registerAbortCallback) { | ||
| options.registerAbortCallback(abortCallback); | ||
| } | ||
|
|
||
| requestObject.setTimeout(readTimeout, () => { | ||
| logEvent(EventSource.HTTP_REQUEST, EventName.TIMEOUT); | ||
| requestObject.socket.destroy(); | ||
|
|
@@ -343,17 +366,6 @@ export const request = ( | |
| }); | ||
| }); | ||
|
|
||
| if (options.dropRequestAfter) { | ||
| setTimeout(() => { | ||
| if (!hasRequestEnded) { | ||
| requestObject.abort(); | ||
| const err = new UserTimeoutError(options, timings); | ||
| logEvent(EventSource.HTTP_REQUEST, EventName.ERROR, err.message); | ||
| reject(err); | ||
| } | ||
| }, options.dropRequestAfter); | ||
| } | ||
|
|
||
| if (options.body) { | ||
| requestObject.write(options.body); | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.