Skip to content

Commit 795c7fe

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-fusion): retry transient transport errors
1 parent 7864589 commit 795c7fe

2 files changed

Lines changed: 116 additions & 8 deletions

File tree

apps/sim/lib/internal/oracle-fusion-financials/client.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ const REQUEST_TIMEOUT_MS = 30_000
1515
const RESPONSE_MAX_BYTES = 5 * 1024 * 1024
1616
const MAX_RETRIES = 2
1717
const TRANSIENT_STATUSES = new Set([429, 503, 504])
18+
const TRANSIENT_TRANSPORT_CODES = new Set([
19+
'ECONNREFUSED',
20+
'ECONNRESET',
21+
'EHOSTUNREACH',
22+
'ENETUNREACH',
23+
'EPIPE',
24+
'ETIMEDOUT',
25+
])
1826
const LOSSLESS_DECIMAL_FIELDS = new Set([
1927
'InvoiceId',
2028
'InvoiceDistributionId',
@@ -95,6 +103,26 @@ function parseOracleFusionJson(body: string): unknown {
95103
})
96104
}
97105

106+
function isTransientTransportError(error: unknown): boolean {
107+
if (!(error instanceof Error)) return false
108+
if (error.message === `Request timed out after ${REQUEST_TIMEOUT_MS}ms`) return true
109+
return (
110+
'code' in error && typeof error.code === 'string' && TRANSIENT_TRANSPORT_CODES.has(error.code)
111+
)
112+
}
113+
114+
async function waitForRetry(
115+
attempt: number,
116+
signal?: AbortSignal,
117+
retryAfterMs: number | null = null
118+
) {
119+
await interruptibleSleep(
120+
backoffWithJitter(attempt + 1, retryAfterMs, { baseMs: 250, maxMs: 5_000 }),
121+
signal
122+
)
123+
signal?.throwIfAborted()
124+
}
125+
98126
export interface OracleFusionRequest {
99127
path: string
100128
query?: Record<string, string | number | boolean | undefined>
@@ -126,7 +154,7 @@ async function fetchAttempt(
126154
})
127155
}
128156

129-
/** Executes one bounded, credential-bound Oracle GET with transient-status retries. */
157+
/** Executes one bounded, credential-bound Oracle GET with transient retries. */
130158
export async function requestOracleFusionJson(
131159
auth: Pick<OracleFusionAuthInput, 'accessToken' | 'instanceUrl'>,
132160
request: OracleFusionRequest,
@@ -154,27 +182,31 @@ export async function requestOracleFusionJson(
154182
let response: SecureFetchResponse
155183
try {
156184
response = await fetchAttempt(url, validation.resolvedIP, auth.accessToken, signal)
157-
} catch {
185+
} catch (error) {
158186
signal?.throwIfAborted()
187+
if (isTransientTransportError(error) && attempt < MAX_RETRIES) {
188+
await waitForRetry(attempt, signal)
189+
continue
190+
}
159191
throw new Error('Could not reach Oracle Fusion Financials')
160192
}
161193

162194
if (TRANSIENT_STATUSES.has(response.status) && attempt < MAX_RETRIES) {
163195
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
164196
await consumeOrCancelBody(response)
165-
await interruptibleSleep(
166-
backoffWithJitter(attempt + 1, retryAfterMs, { baseMs: 250, maxMs: 5_000 }),
167-
signal
168-
)
169-
signal?.throwIfAborted()
197+
await waitForRetry(attempt, signal, retryAfterMs)
170198
continue
171199
}
172200

173201
let body: string
174202
try {
175203
body = await response.text()
176-
} catch {
204+
} catch (error) {
177205
signal?.throwIfAborted()
206+
if (isTransientTransportError(error) && attempt < MAX_RETRIES) {
207+
await waitForRetry(attempt, signal)
208+
continue
209+
}
178210
throw new OracleFusionFinancialsProviderError(
179211
'Oracle Fusion Financials response could not be read',
180212
502

apps/sim/lib/internal/oracle-fusion-financials/oracle-fusion-financials.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,66 @@ describe('Oracle Fusion Financials provider', () => {
10801080
expect(mockSleep).toHaveBeenCalledTimes(2)
10811081
})
10821082

1083+
it('retries classified transient transport failures within the existing bound', async () => {
1084+
mockSecureFetch
1085+
.mockRejectedValueOnce(Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }))
1086+
.mockRejectedValueOnce(new Error('Request timed out after 30000ms'))
1087+
.mockResolvedValueOnce(response(200, page([])))
1088+
1089+
await requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` })
1090+
1091+
expect(mockSecureFetch).toHaveBeenCalledTimes(3)
1092+
expect(mockBackoff).toHaveBeenNthCalledWith(1, 1, null, {
1093+
baseMs: 250,
1094+
maxMs: 5_000,
1095+
})
1096+
expect(mockBackoff).toHaveBeenNthCalledWith(2, 2, null, {
1097+
baseMs: 250,
1098+
maxMs: 5_000,
1099+
})
1100+
expect(mockSleep).toHaveBeenCalledTimes(2)
1101+
})
1102+
1103+
it('stops after two transient transport retries and keeps the failure sanitized', async () => {
1104+
const reset = () => Object.assign(new Error('provider-host-canary'), { code: 'ECONNRESET' })
1105+
mockSecureFetch
1106+
.mockRejectedValueOnce(reset())
1107+
.mockRejectedValueOnce(reset())
1108+
.mockRejectedValueOnce(reset())
1109+
1110+
await expect(
1111+
requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` })
1112+
).rejects.toThrow('Could not reach Oracle Fusion Financials')
1113+
expect(mockSecureFetch).toHaveBeenCalledTimes(3)
1114+
expect(mockSleep).toHaveBeenCalledTimes(2)
1115+
})
1116+
1117+
it('retries a classified transport failure while reading the response body', async () => {
1118+
mockSecureFetch
1119+
.mockResolvedValueOnce({
1120+
...response(200, {}),
1121+
text: async () => {
1122+
throw Object.assign(new Error('socket reset'), { code: 'ECONNRESET' })
1123+
},
1124+
})
1125+
.mockResolvedValueOnce(response(200, page([])))
1126+
1127+
await requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` })
1128+
1129+
expect(mockSecureFetch).toHaveBeenCalledTimes(2)
1130+
expect(mockSleep).toHaveBeenCalledTimes(1)
1131+
})
1132+
1133+
it('does not retry unclassified transport failures', async () => {
1134+
mockSecureFetch.mockRejectedValueOnce(new TypeError('invalid pinned request'))
1135+
1136+
await expect(
1137+
requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` })
1138+
).rejects.toThrow('Could not reach Oracle Fusion Financials')
1139+
expect(mockSecureFetch).toHaveBeenCalledTimes(1)
1140+
expect(mockSleep).not.toHaveBeenCalled()
1141+
})
1142+
10831143
it('stops after two retries and surfaces a sanitized Oracle error', async () => {
10841144
const accessToken = 'short/lived+access~token='
10851145
const encodedAccessToken = encodeURIComponent(accessToken)
@@ -1127,6 +1187,22 @@ describe('Oracle Fusion Financials provider', () => {
11271187
requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` }, duringRetry.signal)
11281188
).rejects.toMatchObject({ name: 'AbortError' })
11291189
expect(mockSecureFetch).toHaveBeenCalledTimes(1)
1190+
1191+
const duringTransportRetry = new AbortController()
1192+
mockSecureFetch.mockRejectedValueOnce(
1193+
Object.assign(new Error('socket reset'), { code: 'ECONNRESET' })
1194+
)
1195+
mockSleep.mockImplementationOnce(async () => {
1196+
duringTransportRetry.abort(new DOMException('cancelled', 'AbortError'))
1197+
})
1198+
await expect(
1199+
requestOracleFusionJson(
1200+
AUTH,
1201+
{ path: `${RESOURCE_PATH}/invoices` },
1202+
duringTransportRetry.signal
1203+
)
1204+
).rejects.toMatchObject({ name: 'AbortError' })
1205+
expect(mockSecureFetch).toHaveBeenCalledTimes(2)
11301206
})
11311207

11321208
it('maps invalid caller input to 400 and malformed Oracle responses to a sanitized 502', async () => {

0 commit comments

Comments
 (0)