Skip to content

Commit 58cc43c

Browse files
committed
feat: add throwOnFailure to waitForCallsStatus
1 parent 97d1eaa commit 58cc43c

File tree

5 files changed

+218
-2
lines changed

5 files changed

+218
-2
lines changed

.changeset/itchy-terms-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"viem": patch
3+
---
4+
5+
Added \`throwOnFailure\` to \`waitForCallsStatus\`.

site/pages/docs/actions/wallet/waitForCallsStatus.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,34 @@ const result = await walletClient.waitForCallsStatus({
8585
})
8686
```
8787

88+
### retryCount
89+
90+
- **Type:** `number`
91+
- **Default:** `4`
92+
93+
Number of times to retry if the call bundle fails.
94+
95+
```ts
96+
const result = await walletClient.waitForCallsStatus({
97+
id: '0xdeadbeef',
98+
retryCount: 10, // [!code focus]
99+
})
100+
```
101+
102+
### retryDelay
103+
104+
- **Type:** `number`
105+
- **Default:** `({ count }) => ~~(1 << count) * 200`
106+
107+
Time to wait (in ms) between retries.
108+
109+
```ts
110+
const result = await walletClient.waitForCallsStatus({
111+
id: '0xdeadbeef',
112+
retryDelay: 1_000, // [!code focus]
113+
})
114+
```
115+
88116
### status
89117

90118
- **Type:** `(parameters: { statusCode: number, status: string | undefined }) => boolean`
@@ -99,6 +127,20 @@ const result = await walletClient.waitForCallsStatus({
99127
})
100128
```
101129

130+
### throwOnFailure
131+
132+
- **Type:** `boolean`
133+
- **Default:** `false`
134+
135+
Whether to throw an error if the call bundle fails.
136+
137+
```ts
138+
const result = await walletClient.waitForCallsStatus({
139+
id: '0xdeadbeef',
140+
throwOnFailure: true, // [!code focus]
141+
})
142+
```
143+
102144
### timeout
103145

104146
- **Type:** `number`

src/actions/wallet/waitForCallsStatus.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { accounts } from '../../../test/src/constants.js'
44
import { mainnet } from '../../chains/index.js'
55
import { createClient } from '../../clients/createClient.js'
66
import { custom } from '../../clients/transports/custom.js'
7+
import { BundleFailedError } from '../../errors/calls.js'
78
import { RpcRequestError } from '../../errors/request.js'
89
import type {
910
WalletCallReceipt,
@@ -238,3 +239,119 @@ test('behavior: `wallet_getCallsStatus` failure', async () => {
238239
}),
239240
).rejects.toThrowError('RPC Request failed.')
240241
})
242+
243+
test('behavior: throwOnFailure = true with failed bundle', async () => {
244+
const client = createClient({
245+
pollingInterval: 100,
246+
transport: custom({
247+
async request({ params }) {
248+
return {
249+
atomic: false,
250+
chainId: '0x1',
251+
id: params[0],
252+
receipts: [],
253+
status: 400,
254+
version: '2.0.0',
255+
} satisfies WalletGetCallsStatusReturnType
256+
},
257+
}),
258+
})
259+
260+
try {
261+
await waitForCallsStatus(client, {
262+
id: 'test-bundle-id',
263+
throwOnFailure: true,
264+
})
265+
} catch (e) {
266+
const error = e as BundleFailedError
267+
expect(error).toBeInstanceOf(BundleFailedError)
268+
expect((error as BundleFailedError).name).toBe('BundleFailedError')
269+
expect((error as BundleFailedError).result.status).toBe('failure')
270+
expect((error as BundleFailedError).result.statusCode).toBe(400)
271+
}
272+
})
273+
274+
test('behavior: throwOnFailure = false with failed bundle (default)', async () => {
275+
const client = createClient({
276+
pollingInterval: 100,
277+
transport: custom({
278+
async request({ params }) {
279+
return {
280+
atomic: false,
281+
chainId: '0x1',
282+
id: params[0],
283+
receipts: [],
284+
status: 400,
285+
version: '2.0.0',
286+
} satisfies WalletGetCallsStatusReturnType
287+
},
288+
}),
289+
})
290+
291+
const id = 'test-bundle-id'
292+
293+
// Should not throw by default (throwOnFailure = false)
294+
const result = await waitForCallsStatus(client, {
295+
id,
296+
})
297+
298+
expect(result.status).toBe('failure')
299+
expect(result.statusCode).toBe(400)
300+
expect(result.id).toBe(id)
301+
})
302+
303+
test('behavior: throwOnFailure = false explicitly with failed bundle', async () => {
304+
const client = createClient({
305+
pollingInterval: 100,
306+
transport: custom({
307+
async request({ params }) {
308+
return {
309+
atomic: false,
310+
chainId: '0x1',
311+
id: params[0],
312+
receipts: [],
313+
status: 500,
314+
version: '2.0.0',
315+
} satisfies WalletGetCallsStatusReturnType
316+
},
317+
}),
318+
})
319+
320+
const id = 'test-bundle-id'
321+
322+
const result = await waitForCallsStatus(client, {
323+
id,
324+
throwOnFailure: false,
325+
})
326+
327+
expect(result.status).toBe('failure')
328+
expect(result.statusCode).toBe(500)
329+
expect(result.id).toBe(id)
330+
})
331+
332+
test('behavior: throwOnFailure = true with successful bundle', async () => {
333+
const client = getClient()
334+
335+
const { id } = await sendCalls(client, {
336+
account: accounts[0].address,
337+
calls: [
338+
{
339+
to: accounts[1].address,
340+
value: parseEther('1'),
341+
},
342+
],
343+
chain: mainnet,
344+
})
345+
346+
expect(id).toBeDefined()
347+
348+
await mine(testClient, { blocks: 1 })
349+
350+
const result = await waitForCallsStatus(client, {
351+
id,
352+
throwOnFailure: true,
353+
})
354+
355+
expect(result.status).toBe('success')
356+
expect(result.statusCode).toBe(200)
357+
})

src/actions/wallet/waitForCallsStatus.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import type { Client } from '../../clients/createClient.js'
22
import type { Transport } from '../../clients/transports/createTransport.js'
33
import { BaseError } from '../../errors/base.js'
4+
import { BundleFailedError } from '../../errors/calls.js'
45
import type { ErrorType } from '../../errors/utils.js'
56
import type { Chain } from '../../types/chain.js'
67
import { type ObserveErrorType, observe } from '../../utils/observe.js'
78
import { type PollErrorType, poll } from '../../utils/poll.js'
89
import { withResolvers } from '../../utils/promise/withResolvers.js'
10+
import {
11+
type WithRetryParameters,
12+
withRetry,
13+
} from '../../utils/promise/withRetry.js'
914
import { stringify } from '../../utils/stringify.js'
1015
import {
1116
type GetCallsStatusErrorType,
@@ -24,12 +29,28 @@ export type WaitForCallsStatusParameters = {
2429
* @default client.pollingInterval
2530
*/
2631
pollingInterval?: number | undefined
32+
/**
33+
* Number of times to retry if the call bundle failed.
34+
* @default 4 (exponential backoff)
35+
*/
36+
retryCount?: WithRetryParameters['retryCount'] | undefined
37+
/**
38+
* Time to wait (in ms) between retries.
39+
* @default `({ count }) => ~~(1 << count) * 200` (exponential backoff)
40+
*/
41+
retryDelay?: WithRetryParameters['delay'] | undefined
2742
/**
2843
* The status range to wait for.
2944
*
3045
* @default (status) => status >= 200
3146
*/
3247
status?: ((parameters: GetCallsStatusReturnType) => boolean) | undefined
48+
/**
49+
* Whether to throw an error if the call bundle fails.
50+
*
51+
* @default false
52+
*/
53+
throwOnFailure?: boolean | undefined
3354
/**
3455
* Optional timeout (in milliseconds) to wait before stopping polling.
3556
*
@@ -76,8 +97,11 @@ export async function waitForCallsStatus<chain extends Chain | undefined>(
7697
const {
7798
id,
7899
pollingInterval = client.pollingInterval,
79-
status = ({ statusCode }) => statusCode >= 200,
100+
status = ({ statusCode }) => statusCode === 200 || statusCode >= 300,
101+
retryCount = 4,
102+
retryDelay = ({ count }) => ~~(1 << count) * 200, // exponential backoff
80103
timeout = 60_000,
104+
throwOnFailure = false,
81105
} = parameters
82106
const observerId = stringify(['waitForCallsStatus', client.uid, id])
83107

@@ -97,7 +121,18 @@ export async function waitForCallsStatus<chain extends Chain | undefined>(
97121
}
98122

99123
try {
100-
const result = await getCallsStatus(client, { id })
124+
const result = await withRetry(
125+
async () => {
126+
const result = await getCallsStatus(client, { id })
127+
if (throwOnFailure && result.status === 'failure')
128+
throw new BundleFailedError(result)
129+
return result
130+
},
131+
{
132+
retryCount,
133+
delay: retryDelay,
134+
},
135+
)
101136
if (!status(result)) return
102137
done(() => emit.resolve(result))
103138
} catch (error) {

src/errors/calls.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { GetCallsStatusReturnType } from '../actions/wallet/getCallsStatus.js'
2+
import { BaseError } from './base.js'
3+
4+
export type BundleFailedErrorType = BundleFailedError & {
5+
name: 'BundleFailedError'
6+
}
7+
export class BundleFailedError extends BaseError {
8+
result: GetCallsStatusReturnType
9+
10+
constructor(result: GetCallsStatusReturnType) {
11+
super(`Call bundle failed with status: ${result.statusCode}`, {
12+
name: 'BundleFailedError',
13+
})
14+
15+
this.result = result
16+
}
17+
}

0 commit comments

Comments
 (0)