fix(core): add retry logic for transient SSL/TLS errors (#17318) (#18310)

This commit is contained in:
Philippe
2026-02-05 16:47:35 +01:00
committed by GitHub
parent 2566057e44
commit e3b8490edf
4 changed files with 315 additions and 7 deletions
+81
View File
@@ -409,6 +409,87 @@ describe('retryWithBackoff', () => {
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
});
it('should retry on SSL error code (ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC)', async () => {
const error = new Error('SSL error');
(error as any).code = 'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC';
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 1,
maxDelayMs: 1,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should retry on SSL error code in deeply nested cause chain', async () => {
const deepCause = new Error('OpenSSL error');
(deepCause as any).code = 'ERR_SSL_BAD_RECORD_MAC';
const middleCause = new Error('TLS handshake failed');
(middleCause as any).cause = deepCause;
const outerError = new Error('fetch failed');
(outerError as any).cause = middleCause;
const mockFn = vi
.fn()
.mockRejectedValueOnce(outerError)
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 1,
maxDelayMs: 1,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should retry on EPROTO error (generic protocol/SSL error)', async () => {
const error = new Error('Protocol error');
(error as any).code = 'EPROTO';
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 1,
maxDelayMs: 1,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should retry on gaxios-style SSL error with code property', async () => {
// This matches the exact structure from issue #17318
const error = new Error(
'request to https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent failed',
);
(error as any).type = 'system';
(error as any).errno = 'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC';
(error as any).code = 'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC';
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 1,
maxDelayMs: 1,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Flash model fallback for OAuth users', () => {
+22 -2
View File
@@ -54,6 +54,12 @@ const RETRYABLE_NETWORK_CODES = [
'ENOTFOUND',
'EAI_AGAIN',
'ECONNREFUSED',
// SSL/TLS transient errors
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC',
'ERR_SSL_WRONG_VERSION_NUMBER',
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
'ERR_SSL_BAD_RECORD_MAC',
'EPROTO', // Generic protocol error (often SSL-related)
];
function getNetworkErrorCode(error: unknown): string | undefined {
@@ -72,8 +78,22 @@ function getNetworkErrorCode(error: unknown): string | undefined {
return directCode;
}
if (typeof error === 'object' && error !== null && 'cause' in error) {
return getCode((error as { cause: unknown }).cause);
// Traverse the cause chain to find error codes (SSL errors are often nested)
let current: unknown = error;
const maxDepth = 5; // Prevent infinite loops in case of circular references
for (let depth = 0; depth < maxDepth; depth++) {
if (
typeof current !== 'object' ||
current === null ||
!('cause' in current)
) {
break;
}
current = (current as { cause: unknown }).cause;
const code = getCode(current);
if (code) {
return code;
}
}
return undefined;