Skip to content

Commit b7c66c0

Browse files
fix(pii): retry runtime timeouts and socket closes in mask-batch chunks
Verified end-to-end against a 26MB / 40k-record offloaded output: the chunk-wise path masks it in ~54s on a single local Presidio worker where the old path aborted at the 16MB ceiling. The exercise surfaced two more transient error shapes the retry classifier missed — runtime-level request timeouts (undici's default 300s headers timeout, Bun's TimeoutError) and mid-flight socket closes — both of which previously failed the whole payload's redaction on the first occurrence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1JYstmLHk9qMGyBDqYRcJ
1 parent ab8db29 commit b7c66c0

2 files changed

Lines changed: 35 additions & 3 deletions

File tree

apps/sim/lib/guardrails/mask-client.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ describe('maskPIIBatchViaHttp', () => {
8282
expect(fetchMock).toHaveBeenCalledTimes(2)
8383
})
8484

85+
it('retries a runtime-level request timeout and then succeeds', async () => {
86+
const timeout = new Error('The operation timed out.')
87+
timeout.name = 'TimeoutError'
88+
fetchMock.mockRejectedValueOnce(timeout)
89+
90+
const out = await maskPIIBatchViaHttp(['a'], [])
91+
92+
expect(out).toEqual(['M(a)'])
93+
expect(fetchMock).toHaveBeenCalledTimes(2)
94+
})
95+
8596
it('gives up after the retry budget is exhausted on a persistent 5xx', async () => {
8697
fetchMock.mockImplementation(async () => new Response('down', { status: 503 }))
8798

apps/sim/lib/guardrails/mask-client.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,30 @@ function isRetryableChunkError(error: unknown): boolean {
4848
if (error instanceof MaskChunkHttpError) {
4949
return RETRYABLE_STATUSES.has(error.status)
5050
}
51-
// A rejected fetch (connection refused/reset, DNS, socket drop) is transient;
52-
// anything else (shape mismatch, token minting failure) is deterministic.
53-
return error instanceof TypeError
51+
// A rejected fetch (connection refused/reset, DNS, socket drop) is transient —
52+
// Node wraps these in TypeError('fetch failed'). Runtime-level request
53+
// timeouts (undici's default 300s headers/body timeout, Bun's TimeoutError)
54+
// and mid-flight socket closes surface with their own names/codes per runtime;
55+
// all are congestion or connection churn, not a deterministic failure: a chunk
56+
// queued behind a saturated Presidio fleet must retry, not fail the payload.
57+
if (error instanceof TypeError) {
58+
return true
59+
}
60+
const { name, code } = (error ?? {}) as { name?: unknown; code?: unknown }
61+
if (name === 'TimeoutError' || name === 'HeadersTimeoutError' || name === 'BodyTimeoutError') {
62+
return true
63+
}
64+
return (
65+
typeof code === 'string' &&
66+
[
67+
'ECONNRESET',
68+
'ECONNREFUSED',
69+
'EPIPE',
70+
'ETIMEDOUT',
71+
'ConnectionClosed',
72+
'ConnectionRefused',
73+
].includes(code)
74+
)
5475
}
5576

5677
/**

0 commit comments

Comments
 (0)