Skip to content

Commit 3425bcb

Browse files
authored
chore: add retries for postman email and sms (#1022)
## TL;DR Add retry handling for the following transient errors: * Postman Email: 500, 524, socket hang up * Postman SMS: 500, 502, 503, read TIMEOUT ## Why make this change? Postman Email and SMS services occasionally return transient errors due to server instability or network issues. These errors are often resolved on subsequent attempts. By retrying affected executions before marking them as failed, we improve overall system resilience and reduce false failure rates in Plumber. ## How to test? - Change the local postman URL to call https://mock.codes - Use a URL param to point to the different http codes - Verify that RetriableError is thrown
1 parent 61845e1 commit 3425bcb

File tree

4 files changed

+142
-26
lines changed

4 files changed

+142
-26
lines changed

packages/backend/src/apps/postman-sms/__tests__/request-error-handler.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,19 +73,43 @@ describe('Postman SMS request error handler', () => {
7373
})
7474

7575
describe('Other errors', () => {
76-
it('throws StepError with generic error message', async () => {
76+
it.each([500, 502, 503])('should retry on %s', async (status) => {
7777
const axiosError = {
7878
isAxiosError: true,
7979
name: 'AxiosError',
8080
message: 'Internal server error',
8181
response: {
82-
status: 500,
82+
status,
8383
},
8484
} as unknown as AxiosError
8585

8686
const error = new HttpError(axiosError)
8787

88-
await expect(requestErrorHandler($, error)).rejects.toThrow(StepError)
88+
await expect(requestErrorHandler($, error)).rejects.toThrow(
89+
RetriableError,
90+
)
91+
await expect(requestErrorHandler($, error)).rejects.toMatchObject({
92+
delayType: 'step',
93+
delayInMs: 3000,
94+
})
95+
})
96+
97+
it('should retry on read ETIMEDOUT', async () => {
98+
const axiosError = {
99+
isAxiosError: true,
100+
name: 'AxiosError',
101+
message: 'read ETIMEDOUT',
102+
} as unknown as AxiosError
103+
104+
const error = new HttpError(axiosError)
105+
106+
await expect(requestErrorHandler($, error)).rejects.toThrow(
107+
RetriableError,
108+
)
109+
await expect(requestErrorHandler($, error)).rejects.toMatchObject({
110+
delayType: 'step',
111+
delayInMs: 3000,
112+
})
89113
})
90114
})
91115
})

packages/backend/src/apps/postman-sms/common/request-error-handler.ts

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,32 +21,53 @@ const handle429: ThrowingHandler = (_, error): never => {
2121
})
2222
}
2323

24+
const handle500and502and503: ThrowingHandler = (_, error): never => {
25+
const status = error.response.status
26+
throw new RetriableError({
27+
error: `Retrying HTTP ${status} from Postman SMS`,
28+
delayType: 'step',
29+
delayInMs: 'default',
30+
})
31+
}
32+
2433
const requestErrorHandler: IApp['requestErrorHandler'] = async function (
2534
$,
2635
error,
2736
) {
28-
if (error.response.status === 429) {
29-
return handle429($, error)
30-
}
37+
switch (error.response.status) {
38+
case 429:
39+
return handle429($, error)
40+
case 500:
41+
case 502:
42+
case 503:
43+
return handle500and502and503($, error)
44+
default:
45+
if (error.message === 'read ETIMEDOUT') {
46+
throw new RetriableError({
47+
error: `Retrying ${error.message} from Postman SMS`,
48+
// pausing the entire queue here is not a good idea because we wont be able to fully utilize
49+
// the throughput that the campaign supports, so we throw step error here instead of group
50+
delayType: 'step',
51+
delayInMs: 'default',
52+
})
53+
}
3154

32-
if (
33-
error.response.status === 400 &&
34-
error.response.data.error?.code === 'parameter_invalid'
35-
) {
36-
throw new StepError(
37-
'Campaign template was not set up correctly',
38-
'Ensure that you have followed the instructions in our guide to set up your campaign template.',
39-
$.step.position,
40-
$.app.name,
41-
)
42-
}
55+
if (error.response.data.error?.code === 'parameter_invalid') {
56+
throw new StepError(
57+
'Campaign template was not set up correctly',
58+
'Ensure that you have followed the instructions in our guide to set up your campaign template.',
59+
$.step.position,
60+
$.app.name,
61+
)
62+
}
4363

44-
throw new StepError(
45-
'Error sending SMS',
46-
error.message,
47-
$.step.position,
48-
$.app.name,
49-
)
64+
throw new StepError(
65+
'Error sending SMS',
66+
error.message,
67+
$.step.position,
68+
$.app.name,
69+
)
70+
}
5071
}
5172

5273
export default requestErrorHandler

packages/backend/src/apps/postman/__tests__/actions/send-transactional-email.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,11 +350,13 @@ describe('send transactional email', () => {
350350
})
351351
})
352352

353-
it('should retry on 502, 504, 520', async () => {
353+
it('should retry on 500, 502, 504, 520, 524', async () => {
354354
const recipients = [
355355
356356
357357
358+
359+
358360
]
359361
$.step.parameters.destinationEmail = recipients.join(',')
360362
$.http.post = vi
@@ -387,11 +389,72 @@ describe('send transactional email', () => {
387389
},
388390
} as AxiosError),
389391
)
392+
.mockRejectedValueOnce(
393+
new HttpError({
394+
response: {
395+
data: '<html>cloudflare error</html>',
396+
status: 520,
397+
statusText: 'Web server is returning an unknown error',
398+
},
399+
} as AxiosError),
400+
)
401+
.mockRejectedValueOnce(
402+
new HttpError({
403+
response: {
404+
data: '<html>cloudflare error</html>',
405+
status: 524,
406+
statusText: 'A timeout occurred',
407+
},
408+
} as AxiosError),
409+
)
410+
411+
await expect(sendTransactionalEmail.run($)).rejects.toThrow(RetriableError)
412+
expect($.setActionItem).toHaveBeenCalledWith({
413+
raw: {
414+
status: [
415+
'ACCEPTED',
416+
'INTERMITTENT-ERROR',
417+
'INTERMITTENT-ERROR',
418+
'INTERMITTENT-ERROR',
419+
'INTERMITTENT-ERROR',
420+
],
421+
recipient: recipients,
422+
subject: 'test subject',
423+
body: 'test body',
424+
from: 'jack',
425+
reply_to: '[email protected]',
426+
},
427+
})
428+
})
390429

430+
it('should retry on socket hang up', async () => {
431+
const recipients = ['[email protected]', '[email protected]']
432+
$.step.parameters.destinationEmail = recipients.join(',')
433+
$.http.post = vi
434+
.fn()
435+
.mockResolvedValueOnce({
436+
data: {
437+
params: {
438+
body: 'test body',
439+
subject: 'test subject',
440+
from: 'jack',
441+
reply_to: '[email protected]',
442+
},
443+
},
444+
})
445+
.mockRejectedValueOnce(
446+
new HttpError({
447+
response: {
448+
data: 'socket hang up',
449+
status: 400,
450+
statusText: 'socket hang up',
451+
},
452+
} as AxiosError),
453+
)
391454
await expect(sendTransactionalEmail.run($)).rejects.toThrow(RetriableError)
392455
expect($.setActionItem).toHaveBeenCalledWith({
393456
raw: {
394-
status: ['ACCEPTED', 'ERROR', 'INTERMITTENT-ERROR'],
457+
status: ['ACCEPTED', 'ERROR'],
395458
recipient: recipients,
396459
subject: 'test subject',
397460
body: 'test body',

packages/backend/src/apps/postman/common/throw-errors.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type PostmanApiErrorData = {
1616
// These are HTTP error codes returned by Cloudflare, which likely indicate
1717
// that Postman's origin server did not receive the request.
1818
// Until this is fixed, we will retry these requests on behalf of the user
19-
const POSTMAN_RETRIABLE_HTTP_CODES = [502, 504, 520]
19+
const POSTMAN_RETRIABLE_HTTP_CODES = [500, 502, 504, 520, 524]
2020

2121
export function getPostmanErrorStatus(
2222
error: HttpError,
@@ -121,6 +121,14 @@ export function throwPostmanStepError({
121121
})
122122
case 'ERROR':
123123
default:
124+
if (error.message === 'socket hang up') {
125+
throw new RetriableError({
126+
error: `Retrying ${error.message} from Postman`,
127+
delayInMs: 'default',
128+
delayType: 'step',
129+
})
130+
}
131+
124132
throw new StepError(
125133
'Something went wrong',
126134
'Please contact [email protected] for assistance.',

0 commit comments

Comments
 (0)