-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(node): add exponential retry mechanism with idempotency headers #4462
feat(node): add exponential retry mechanism with idempotency headers #4462
Conversation
@Cliftonz I'm wondering about the retry condition. I think it should be clearly defined in requirements. What I plan to do:for idempotent http methods SDK will retry on 50x errors + network errors without response. I will not apply for non-idempotent http methods SDK will retry only on network errors without response / maybe on |
And also, there should be an option for setting retryCondition callback should from userland. |
Just to make sure we are on the same page, these should be configurable by the client. As for applying the the idempotency header, you should only do it to POST and PATCH methods however we can add it to all calls its just not preferred. As for which errors that should be retried, not all errors are considered equal and here are the Errors that should be considered for retrying: Errors that generally should not be retried without addressing the cause: |
@michaldziuba03 I also agree with @dammy001 we should provide a callback for the user to dynamically select the condition if they so want. |
packages/node/src/lib/retries.ts
Outdated
axios.interceptors.request.use((axiosConfig) => { | ||
// don't attach idempotency key for idempotent methods | ||
if ( | ||
axiosConfig.method && | ||
!NON_IDEMPOTENT_METHODS.includes(axiosConfig.method) | ||
) { | ||
return axiosConfig; | ||
} | ||
|
||
const idempotencyKey = axiosConfig.headers['Idempotency-Key']; | ||
// that means intercepted request is retried, so don't generate new idempotency key | ||
if (idempotencyKey) { | ||
return axiosConfig; | ||
} | ||
|
||
axiosConfig.headers['Idempotency-Key'] = uuid(); | ||
|
||
return axiosConfig; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm using axios interceptor to attach idempotency key header to each request.
Idempotency key is attached only to POST
and PATCH
methods because they are not idempotent by definition.
packages/node/src/lib/retries.ts
Outdated
// retry on TCP/IP error codes like ECONNRESET | ||
if (isNetworkError(err)) { | ||
return true; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isNetworkError
is imported from axios-retry
library.
As we know HTTP is based on TCP/IP protocol and sometimes we facing issues on this network level.
TCP/IP error typically contains some code
like "ECONNRESET"
and isNetworkError
uses under the hood library is-retry-allowed, because not every network error should be retried.
packages/node/src/lib/retries.ts
Outdated
if (err.response.status >= 500 && err.response.status <= 599) { | ||
return true; | ||
} | ||
|
||
if (RETRIABLE_HTTP_CODES.includes(err.response.status)) { | ||
return true; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On HTTP level, I decided to retry on every 5xx
error, 408
(Request Timeout) and 429
(Too Many Requests)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should also retry on 422 but regen the idempotency key
@@ -77,7 +81,7 @@ | |||
"preset": "ts-jest", | |||
"testEnvironment": "node", | |||
"moduleNameMapper": { | |||
"axios": "axios/dist/node/axios.cjs" | |||
"^axios$": "axios/dist/node/axios.cjs" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
without that change, tests for retries don't work.
I observed strange behavior - it sends GET request to 127.0.0.1:80
even when you send POST request to baseUrl, it messes up the request config.
change to ^axios$
fixed that issue. This is also recommendation inside nock readme for axios.
I also tested manually using symlink in testing repo's node_modules
and retries work as expected.
@michaldziuba03 Great overview of the PR! |
packages/node/src/lib/retries.ts
Outdated
onRetry(_retryCount, error, requestConfig) { | ||
if ( | ||
error.response?.status === 422 && | ||
requestConfig.headers && | ||
requestConfig.method && | ||
NON_IDEMPOTENT_METHODS.includes(requestConfig.method) | ||
) { | ||
requestConfig.headers['Idempotency-Key'] = uuid(); | ||
} | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Cliftonz regenerating idempotency key for HTTP status 422 Unprocessable Content
const result = await novuClient.topics.list({}); | ||
expect(result.status).toEqual(200); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice test 🙌
expect(result.request.headers['idempotency-key']).toBeUndefined(); | ||
}); | ||
|
||
it('should not retry on common 4xx errors', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: common
isn't quite the correct term here, non-recoverable
would be more descriptive.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Secondly, this is a good case for using a jest loop to test all relevant 4xx errors. It will make the tests stronger and more declarative.
|
||
expect(result.status).toEqual(200); | ||
expect(result.request.headers['idempotency-key']).toBeUndefined(); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is great. Could we add another test using the same format with times(4)
and verify that a 500
is received?
packages/node/src/lib/retries.ts
Outdated
|
||
if (!err.response) { | ||
return true; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not clear why we're retrying when a response is unavailable. Could you please shed some light on this one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's mistake (should return false
) - if statements after this check are dependent on err.response
(possibly undefined). In new changes I made it look cleaner.
packages/node/src/lib/retry.spec.ts
Outdated
it('should generate different idempotency-key for each request', async () => { | ||
nock(BACKEND_URL) | ||
.post(TRIGGER_PATH) | ||
.reply(500, { message: 'Server Exception' }); | ||
|
||
nock(BACKEND_URL) | ||
.post(TRIGGER_PATH) | ||
.times(10) | ||
.reply(201, { acknowledged: true, transactionId: '1003' }); | ||
|
||
const idempotencyKeys: string[] = []; | ||
|
||
for (let i = 0; i < 10; i++) { | ||
const result = await novu.trigger('fake-workflow', { | ||
to: { subscriberId: '123' }, | ||
payload: {}, | ||
}); | ||
|
||
idempotencyKeys.push(result.request?.headers['idempotency-key']); | ||
} | ||
|
||
expect(allEqual(idempotencyKeys)).toEqual(false); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
new test case - I want to make sure that idempotency key is different between requests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch 🎉
packages/node/src/lib/retry.ts
Outdated
|
||
const NON_IDEMPOTENT_METHODS = ['post', 'patch']; | ||
const IDEMPOTENCY_KEY = 'Idempotency-Key'; // header key | ||
|
||
const DEFAULT_RETRY_MAX = 0; | ||
const DEFAULT_WAIT_MIN = 1; | ||
const DEFAULT_WAIT_MAX = 30; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
new global variables - for default values as suggested in review + I decided to move string value for header key to IDEMPOTENCY_KEY
variable (to avoid "magic strings" as it's used in multiple places)
packages/node/src/lib/retry.spec.ts
Outdated
|
||
jest.setTimeout(15000); | ||
|
||
const allEqual = (arr: Array<string>) => arr.every((val) => val === arr[0]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const allEqual = (arr: Array<string>) => arr.every((val) => val === arr[0]); | |
const hasUniqueOnly = (arr: Array<string>) => Array.from(new Set(arr)).length === arr.length); |
Consider the case where every array item except the first is the same, like [1,2,2,2,2]
. This function will currently return true
, because it's only comparing entries against the first item, not all of the entries.
So we'll need to instead verify that the number of unique entries in the array is equal to the number of total entries. new Set(Array<any>)
constructs a Set comprising unique only values. We can then transform the Set to an Array for a length comparison. See https://stackoverflow.com/a/14438954 for reference.
We should also use the convention of is
/has
(is
for singular test against a primitive, has
for plural test, like an array) prefix for a boolean function, to provide a better description for the result.
Lastly, it's generally a good idea to also add tests for helper functions. If these helper functions aren't working as expected, how can we be sure that the feature under test is too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I disagree with second sentence but I understand where problem is. Function allEqual
(currently renamed to hasAllEqual
) works well when our goal is to check if all items are equal (it iterates over all elements and compares them to the first element, it also compares first element with itself). This is the goal when we check in tests if idempotency-keys are equal between retries (in the scope of same request).
I agree this function is used in wrong way when we check if idempotency-keys are unique between requests - we expect allEqual
to return false
. Negation of this function does not guarantee that all items are unique, so this expectation will pass in case of [1,2,2,2,2]
scenario, but shouldn't.
I decided to let them coexist :)
await expect(novu.topics.list({})).rejects.toMatchObject({ | ||
response: { status: 500 }, | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great job 👏
packages/node/src/lib/retry.spec.ts
Outdated
it('should generate different idempotency-key for each request', async () => { | ||
nock(BACKEND_URL) | ||
.post(TRIGGER_PATH) | ||
.reply(500, { message: 'Server Exception' }); | ||
|
||
nock(BACKEND_URL) | ||
.post(TRIGGER_PATH) | ||
.times(10) | ||
.reply(201, { acknowledged: true, transactionId: '1003' }); | ||
|
||
const idempotencyKeys: string[] = []; | ||
|
||
for (let i = 0; i < 10; i++) { | ||
const result = await novu.trigger('fake-workflow', { | ||
to: { subscriberId: '123' }, | ||
payload: {}, | ||
}); | ||
|
||
idempotencyKeys.push(result.request?.headers['idempotency-key']); | ||
} | ||
|
||
expect(allEqual(idempotencyKeys)).toEqual(false); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch 🎉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great job @michaldziuba03 🙌
describe('defaultRetryCondition function', () => { | ||
test.each<[number, string]>(NON_RECOVERABLE_ERRORS)( | ||
'should return false when HTTP status is %i', | ||
(status) => { | ||
const err = new HttpError(status); | ||
expect(defaultRetryCondition(err as AxiosError)).toEqual(false); | ||
} | ||
); | ||
|
||
test.each<number>(RETRYABLE_HTTP_CODES)( | ||
'should return true when HTTP status is %i', | ||
(status) => { | ||
const err = new HttpError(status); | ||
expect(defaultRetryCondition(err as AxiosError)).toEqual(true); | ||
} | ||
); | ||
|
||
it('should return true when HTTP status is 500', () => { | ||
const err = new HttpError(500); | ||
expect(defaultRetryCondition(err as AxiosError)).toEqual(true); | ||
}); | ||
|
||
it('should return true when network code is ECONNRESET', () => { | ||
const err = new NetworkError('ECONNRESET'); | ||
expect(defaultRetryCondition(err as AxiosError)).toEqual(true); | ||
}); | ||
|
||
it('shoud return false on unknown error', () => { | ||
const err = new Error('Unexpected error'); | ||
expect(defaultRetryCondition(err as AxiosError)).toEqual(false); | ||
}); | ||
}); | ||
|
||
describe('hasAllEqual helper function', () => { | ||
it('should return true when all items are equal', () => { | ||
const arr = ['a', 'a', 'a', 'a']; | ||
expect(hasAllEqual(arr)).toEqual(true); | ||
}); | ||
|
||
it('should return false when items are not equal', () => { | ||
const arr = ['a', 'b', 'b', 'b']; | ||
expect(hasAllEqual(arr)).toEqual(false); | ||
}); | ||
}); | ||
|
||
describe('hasUniqueOnly helper function', () => { | ||
it('should return true when all items are unique', () => { | ||
const arr = ['a', 'b', 'c', 'd']; | ||
expect(hasUniqueOnly(arr)).toEqual(true); | ||
}); | ||
|
||
it('should return false when items are not unique', () => { | ||
const arr = ['a', 'a', 'c', 'd']; | ||
expect(hasUniqueOnly(arr)).toEqual(false); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent tests 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hey @michaldziuba03 , just a few spellcheck issues to resolve then we are good to merge 🙌
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rifont done
@rifont we are waiting for something before merge? Spellcheck issues were resolved week ago. Let me know if there is something else to do. |
Hi @michaldziuba03 , we had some failing tests on |
What change does this PR introduce?
This PR introduces exponential retry mechanism with idempotency headers for Node SDK.
PR contains:
POST
andPATCH
requestWhy was this change needed?
Closes #4452
Other information (Screenshots)