Skip to content

Commit 20effb2

Browse files
committed
acl-2909: retry on ECONNRESET / EPIPE / EAI_AGAIN
Expands HttpClient's retryable axios error code allowlist so transient transport failures (TCP RST mid-flight, broken pipe, transient DNS) are retried instead of bubbling up as hard errors on the first attempt. Aligns deepl-node behavior with deepl-python, which retries any requests.exceptions.ConnectionError. Motivating incident: ritabot org saw four parallel /v2/translate calls fail with ECONNRESET ("socket hang up") on 2026-04-18 22:51:27 UTC; the SDK classified all four as non-retryable and surfaced them as user- visible errors. With this change, the SDK would have retried. The retryable-codes set is exported as @internal so the contract is testable; ECONNREFUSED, ENOTFOUND, and CERT_HAS_EXPIRED remain non- retryable.
1 parent d54b131 commit 20effb2

3 files changed

Lines changed: 41 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Fixed
9+
- HTTP requests that fail with `ECONNRESET`, `EPIPE`, or `EAI_AGAIN` are now
10+
retried. Previously these were classified as non-retryable, surfacing
11+
transient transport failures (e.g. stale keep-alive sockets) as hard errors
12+
on the first attempt. Behavior now aligns with deepl-python.
13+
814
### Security
915
- Bump follow-redirects to 1.16.0 due to GHSA-r4q5-vmmm-2653.
1016

src/client.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ import * as http from 'http';
1515

1616
type HttpMethod = 'GET' | 'DELETE' | 'POST' | 'PUT' | 'PATCH';
1717

18+
/**
19+
* Axios error codes for transient transport-level failures that should be retried.
20+
* Mirrors deepl-python's broader ConnectionError retry coverage.
21+
* @internal
22+
*/
23+
export const RETRYABLE_AXIOS_ERROR_CODES = new Set([
24+
'ETIMEDOUT',
25+
'ECONNABORTED',
26+
'ECONNRESET',
27+
'EPIPE',
28+
'EAI_AGAIN',
29+
]);
30+
1831
const axiosInstance = axios.create({
1932
httpAgent: new http.Agent({ keepAlive: true }),
2033
httpsAgent: new https.Agent({ keepAlive: true }),
@@ -261,9 +274,7 @@ export class HttpClient {
261274

262275
const error = new ConnectionError(`Connection failure: ${message}`);
263276
error.error = axiosError;
264-
if (axiosError.code === 'ETIMEDOUT') {
265-
error.shouldRetry = true;
266-
} else if (axiosError.code === 'ECONNABORTED') {
277+
if (axiosError.code !== undefined && RETRYABLE_AXIOS_ERROR_CODES.has(axiosError.code)) {
267278
error.shouldRetry = true;
268279
} else {
269280
logDebug('Unrecognized axios error', axiosError);

tests/client.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import * as deepl from 'deepl-node';
66

77
import { exampleText, makeDeeplClient, makeTranslator } from './core';
8+
import { RETRYABLE_AXIOS_ERROR_CODES } from '../src/client';
89
import log from 'loglevel';
910

1011
jest.mock('loglevel', () => ({
@@ -17,6 +18,26 @@ jest.mock('loglevel', () => ({
1718
}));
1819

1920
describe('client tests', () => {
21+
describe('retry classification', () => {
22+
it.each([
23+
['ETIMEDOUT'],
24+
['ECONNABORTED'],
25+
['ECONNRESET'],
26+
['EPIPE'],
27+
['EAI_AGAIN'],
28+
])('treats axios error code %s as retryable', (code) => {
29+
expect(RETRYABLE_AXIOS_ERROR_CODES.has(code)).toBe(true);
30+
});
31+
32+
it.each([
33+
['ENOTFOUND'],
34+
['ECONNREFUSED'],
35+
['CERT_HAS_EXPIRED'],
36+
])('does not treat axios error code %s as retryable', (code) => {
37+
expect(RETRYABLE_AXIOS_ERROR_CODES.has(code)).toBe(false);
38+
});
39+
});
40+
2041
describe('log debug', () => {
2142
beforeEach(() => {
2243
jest.clearAllMocks();

0 commit comments

Comments
 (0)