Skip to content

Commit b03b597

Browse files
fix(OUT-3544): strict retry classifier for non-idempotent write paths
The OUT-3544 broadening of the retry classifier (429 → 429 + 5xx + network) applies uniformly to every wrapped call, including QBO write endpoints (`createInvoice`, `createCustomer`, `createItem`, `createPayment`, `createAccount`, `createPurchase`) which have no Intuit-side request-key dedupe. A 5xx or network error after the upstream commits could cause pRetry to replay the write and produce a duplicate financial record. Adds an `idempotent` option to `withRetry` / `isRetryableError`. In strict mode (`idempotent: false`), the classifier retries only on 429 and explicit `RetryableError.retry === true`; 5xx, network codes, and AbortSignal timeouts are all treated as possibly-after-commit and never replayed. Inverts the convention in both QBO-facing wrappers: - `IntuitAPI.wrapWithRetry` defaults to strict; `customQuery` (the only read in the wrapped set) opts back into broad retry. All 13 wrapped writes (create/update/void/delete) inherit strict by default, so any future write method added without options is automatically safe. - `Intuit.wrapWithRetry` (OAuth) also defaults to strict. `createToken` and `refreshAccessToken` consume single-use credentials and would previously misdiagnose a 5xx-after-commit refresh as `invalid_grant` via `tokenRefresh.handleInvalidGrant`, throwing `QBReconnectRequiredError` for a healthy connection. Strict mode lets the transport error surface honestly. Both wrappers merge `{ idempotent: false, ...options }` rather than relying on a parameter default, so an explicitly-passed `{}` cannot silently flip back to the global broad-retry default. Out of scope (not addressed here): - The same after-commit-with-dropped-response can still produce a duplicate when the 3-hour resync re-runs the write. Eliminating that requires QBO's `requestid` query parameter on POST endpoints — a separate ticket. - `copilotAPI.ts` / `authenticate.ts` retain the global `idempotent: true` default. None of those calls are QBO writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fe883c8 commit b03b597

4 files changed

Lines changed: 167 additions & 43 deletions

File tree

src/app/api/core/utils/withRetry.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import pRetry, { FailedAttemptError } from 'p-retry'
22
import * as Sentry from '@sentry/nextjs'
33
import { RetryableError } from '@/utils/error'
44

5-
const RETRYABLE_HTTP_STATUSES: ReadonlySet<number> = new Set([
6-
429, // rate limit
7-
500, // internal server error (often transient at QBO)
8-
502, // bad gateway (upstream proxy hiccup)
9-
503, // service unavailable
10-
504, // gateway timeout
5+
// 429 means the server explicitly rejected the request without processing it,
6+
// so retrying is always safe regardless of idempotency.
7+
const ALWAYS_RETRY_STATUSES: ReadonlySet<number> = new Set([429])
8+
9+
// 5xx and network/timeout errors straddle "did not commit" and "committed but
10+
// response dropped." Safe to replay on idempotent reads; on non-idempotent
11+
// writes lacking a server-side request-key primitive, a retry after the upstream
12+
// committed would duplicate the write — so the classifier returns false for
13+
// these in `idempotent: false` mode.
14+
const IDEMPOTENT_ONLY_RETRY_STATUSES: ReadonlySet<number> = new Set([
15+
500, 502, 503, 504,
1116
])
1217

1318
const RETRYABLE_NETWORK_CODES: ReadonlySet<string> = new Set([
@@ -23,36 +28,34 @@ const RETRYABLE_ERROR_NAMES: ReadonlySet<string> = new Set([
2328
// AbortController.abort() and retrying would defeat the cancellation.
2429
])
2530

31+
export type RetryOptions = {
32+
/**
33+
* False for non-idempotent writes (QBO create/update/void/delete) whose
34+
* upstream has no request-key dedupe. In strict mode the classifier
35+
* retries only on 429 and explicit `RetryableError.retry === true`;
36+
* 5xx, network errors, and AbortSignal timeouts all bubble. Defaults
37+
* to true (safe-to-replay reads).
38+
*/
39+
idempotent?: boolean
40+
}
41+
2642
/**
27-
* Centralized classifier for whether an error should trigger a retry.
28-
* Exported so it can be unit-tested independent of pRetry's timer plumbing.
29-
*
30-
* The error shapes inspected here come from different layers; a single
31-
* unified type doesn't exist, which is why the input is `unknown` and
32-
* each field is checked defensively:
43+
* Classifies whether an error should trigger a retry. 429 and explicit
44+
* `RetryableError.retry === true` retry in both modes. Strict mode
45+
* (`options.idempotent === false`) short-circuits past 429: 5xx, network
46+
* codes, AbortSignal timeouts, and undici fetch-failed envelopes are all
47+
* treated as possibly-after-commit and never replayed.
3348
*
34-
* - `RetryableError` (ours, src/utils/error.ts) — explicit retry flag.
35-
* - `HttpFetchError` (ours) and Copilot SDK's `StatusableError` —
36-
* `status: number` set after a non-2xx response was received.
37-
* - undici (Node fetch) network failures — thrown as
38-
* `TypeError: fetch failed` with the underlying error on `.cause`
39-
* (e.g. `{ code: 'ECONNRESET' }`). No HTTP response was ever built,
40-
* so there is no status to inspect.
41-
* - `AbortSignal.timeout()` rejects with a `DOMException` whose
42-
* `name` is `'TimeoutError'` (retryable). `AbortController.abort()`
43-
* produces `'AbortError'` and is NOT retried (deliberate cancellation).
44-
* - Top-level `error.code` is checked defensively for legacy Node
45-
* error paths; in current Node fetch the code lives under `.cause`.
46-
*
47-
* Retry-nesting hazard: do not call a `withRetry`-wrapped function from
48-
* inside another `withRetry`-wrapped function. With the broadened retry
49-
* set, worst-case wait is `outer × inner × per_call_timeout`, which can
50-
* blow past the 300s webhook execution budget. Inside `IntuitAPI._*`
51-
* methods that are themselves wrapped at the public level (see exports
52-
* at the bottom of `src/utils/intuitAPI.ts`), call the unwrapped `_*`
53-
* counterparts directly (e.g. `this._customQuery`, not `this.customQuery`).
49+
* Retry-nesting hazard: don't call a wrapped function from inside another.
50+
* Inside `IntuitAPI._*` methods call the unwrapped `_*` counterparts
51+
* (e.g. `this._customQuery`, not `this.customQuery`).
5452
*/
55-
export const isRetryableError = (error: unknown): boolean => {
53+
export const isRetryableError = (
54+
error: unknown,
55+
options: RetryOptions = {},
56+
): boolean => {
57+
const { idempotent = true } = options
58+
5659
if (error instanceof RetryableError) return error.retry
5760

5861
if (typeof error !== 'object' || error === null) return false
@@ -65,7 +68,16 @@ export const isRetryableError = (error: unknown): boolean => {
6568
cause?: unknown
6669
}
6770

68-
if (typeof err.status === 'number' && RETRYABLE_HTTP_STATUSES.has(err.status))
71+
if (typeof err.status === 'number' && ALWAYS_RETRY_STATUSES.has(err.status))
72+
return true
73+
74+
// Strict mode: nothing past this point is post-commit-safe.
75+
if (!idempotent) return false
76+
77+
if (
78+
typeof err.status === 'number' &&
79+
IDEMPOTENT_ONLY_RETRY_STATUSES.has(err.status)
80+
)
6981
return true
7082

7183
if (typeof err.name === 'string' && RETRYABLE_ERROR_NAMES.has(err.name))
@@ -104,6 +116,7 @@ export const isRetryableError = (error: unknown): boolean => {
104116
export const withRetry = async <T>(
105117
fn: (...args: any[]) => Promise<T>,
106118
args: any[],
119+
options: RetryOptions = {},
107120
): Promise<T> => {
108121
let isEventProcessorRegistered = false
109122

@@ -150,7 +163,7 @@ export const withRetry = async <T>(
150163
error,
151164
)
152165
},
153-
shouldRetry: (error: unknown) => isRetryableError(error),
166+
shouldRetry: (error: unknown) => isRetryableError(error, options),
154167
},
155168
)
156169
}

src/utils/intuit.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { IntuitOAuthError } from '@/app/api/core/exceptions/custom'
2-
import { withRetry } from '@/app/api/core/utils/withRetry'
2+
import { RetryOptions, withRetry } from '@/app/api/core/utils/withRetry'
33
import {
44
intuitClientId,
55
intuitClientSecret,
@@ -79,10 +79,16 @@ export default class Intuit {
7979
return tokenInfo
8080
}
8181

82+
// `createToken` / `refreshAccessToken` consume single-use credentials with
83+
// no Intuit-side dedupe; a post-commit retry sees `invalid_grant` and
84+
// would be misdiagnosed as revocation by `tokenRefresh.handleInvalidGrant`.
85+
// So the wrapper defaults to strict, same as `IntuitAPI.wrapWithRetry`.
8286
private wrapWithRetry<Args extends unknown[], R>(
8387
fn: (...args: Args) => Promise<R>,
88+
options?: RetryOptions,
8489
): (...args: Args) => Promise<R> {
85-
return (...args: Args): Promise<R> => withRetry(fn.bind(this), args)
90+
return (...args: Args): Promise<R> =>
91+
withRetry(fn.bind(this), args, { idempotent: false, ...options })
8692
}
8793

8894
authorizeUri = this.wrapWithRetry(this._authorizeUri)

src/utils/intuitAPI.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import APIError from '@/app/api/core/exceptions/api'
2-
import { withRetry } from '@/app/api/core/utils/withRetry'
2+
import { RetryOptions, withRetry } from '@/app/api/core/utils/withRetry'
33
import { intuitApiMinorVersion, intuitBaseUrl } from '@/config'
44
import { QBPortalConnectionSelectSchemaType } from '@/db/schema/qbPortalConnections'
55
import { getFetcher, postFetcher } from '@/helper/fetch.helper'
@@ -789,14 +789,17 @@ export default class IntuitAPI {
789789

790790
private wrapWithRetry<Args extends unknown[], R>(
791791
fn: (...args: Args) => Promise<R>,
792+
options?: RetryOptions,
792793
): (...args: Args) => Promise<R> {
793-
return (...args: Args): Promise<R> => withRetry(fn.bind(this), args)
794+
return (...args: Args): Promise<R> =>
795+
withRetry(fn.bind(this), args, { idempotent: false, ...options })
794796
}
795797

796-
// Wrap convention: writes + customQuery are wrapped here. Read methods
797-
// (get*) compose customQuery and stay unwrapped to avoid nested withRetry
798-
// (see withRetry.ts).
799-
customQuery = this.wrapWithRetry(this._customQuery)
798+
// Writes default to `idempotent: false` (no QBO request-key dedupe — a
799+
// post-commit retry would duplicate). `customQuery` is the one read in
800+
// this set and opts back into broad retry. `get*` stay unwrapped and
801+
// inherit retry via `customQuery` (see withRetry.ts on nesting).
802+
customQuery = this.wrapWithRetry(this._customQuery, { idempotent: true })
800803
createInvoice = this.wrapWithRetry(this._createInvoice)
801804
createCustomer = this.wrapWithRetry(this._createCustomer)
802805
createItem = this.wrapWithRetry(this._createItem)

test/unit/core/withRetry.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,59 @@ describe('isRetryableError', () => {
5252
expect(isRetryableError(undefined)).toBe(false)
5353
expect(isRetryableError('oops')).toBe(false)
5454
})
55+
56+
describe('idempotent: false (write-safety mode)', () => {
57+
it('retries 429 — server explicitly rejected without committing', () => {
58+
expect(
59+
isRetryableError(Object.assign(new Error(), { status: 429 }), {
60+
idempotent: false,
61+
}),
62+
).toBe(true)
63+
})
64+
65+
it('honors explicit RetryableError.retry regardless of idempotent option', () => {
66+
expect(
67+
isRetryableError(new RetryableError(500, 'x', true), {
68+
idempotent: false,
69+
}),
70+
).toBe(true)
71+
expect(
72+
isRetryableError(new RetryableError(500, 'x', false), {
73+
idempotent: false,
74+
}),
75+
).toBe(false)
76+
})
77+
78+
it('does NOT retry 5xx — commit-then-dropped-response would duplicate', () => {
79+
for (const status of [500, 502, 503, 504]) {
80+
expect(
81+
isRetryableError(Object.assign(new Error(), { status }), {
82+
idempotent: false,
83+
}),
84+
).toBe(false)
85+
}
86+
})
87+
88+
it('does NOT retry network errors — ECONNRESET/ETIMEDOUT can occur mid-response', () => {
89+
const econnreset = Object.assign(new TypeError('fetch failed'), {
90+
cause: { code: 'ECONNRESET' },
91+
})
92+
expect(isRetryableError(econnreset, { idempotent: false })).toBe(false)
93+
94+
const etimedout = Object.assign(new Error('boom'), { code: 'ETIMEDOUT' })
95+
expect(isRetryableError(etimedout, { idempotent: false })).toBe(false)
96+
97+
const fetchFailed = new TypeError('fetch failed')
98+
expect(isRetryableError(fetchFailed, { idempotent: false })).toBe(false)
99+
})
100+
101+
it('does NOT retry AbortSignal.timeout — could fire after request committed', () => {
102+
const timeout = Object.assign(new Error('timed out'), {
103+
name: 'TimeoutError',
104+
})
105+
expect(isRetryableError(timeout, { idempotent: false })).toBe(false)
106+
})
107+
})
55108
})
56109

57110
describe('withRetry', () => {
@@ -181,4 +234,53 @@ describe('withRetry', () => {
181234
// 1 initial + 4 retries = 5 total
182235
expect(fn).toHaveBeenCalledTimes(5)
183236
})
237+
238+
it('does not retry 5xx when idempotent: false', async () => {
239+
const error = Object.assign(new Error('upstream 503'), { status: 503 })
240+
const fn = vi.fn().mockRejectedValue(error)
241+
242+
await expect(withRetry(fn, [], { idempotent: false })).rejects.toThrow(
243+
'upstream 503',
244+
)
245+
expect(fn).toHaveBeenCalledTimes(1)
246+
})
247+
248+
it('does not retry network errors when idempotent: false', async () => {
249+
const error = Object.assign(new TypeError('fetch failed'), {
250+
cause: { code: 'ECONNRESET' },
251+
})
252+
const fn = vi.fn().mockRejectedValue(error)
253+
254+
await expect(withRetry(fn, [], { idempotent: false })).rejects.toThrow(
255+
'fetch failed',
256+
)
257+
expect(fn).toHaveBeenCalledTimes(1)
258+
})
259+
260+
it('still retries 429 when idempotent: false — server explicitly rejected', async () => {
261+
const error = Object.assign(new Error('rate limited'), { status: 429 })
262+
const fn = vi
263+
.fn()
264+
.mockRejectedValueOnce(error)
265+
.mockResolvedValueOnce('recovered')
266+
267+
const promise = withRetry(fn, [], { idempotent: false })
268+
await vi.runAllTimersAsync()
269+
270+
expect(await promise).toBe('recovered')
271+
expect(fn).toHaveBeenCalledTimes(2)
272+
})
273+
274+
it('still retries RetryableError(retry=true) when idempotent: false', async () => {
275+
const fn = vi
276+
.fn()
277+
.mockRejectedValueOnce(new RetryableError(500, 'transient', true))
278+
.mockResolvedValueOnce('ok')
279+
280+
const promise = withRetry(fn, [], { idempotent: false })
281+
await vi.runAllTimersAsync()
282+
283+
expect(await promise).toBe('ok')
284+
expect(fn).toHaveBeenCalledTimes(2)
285+
})
184286
})

0 commit comments

Comments
 (0)