Skip to content

Commit b3fec25

Browse files
authored
feat(multiclient): adds timeouts to retryWithBackups and dialWithRetry (#84)
This PR enhances the `MultiClient` implementation by adding timeout support and improving test coverage. Timeout Handling: - Added `Timeout` and `DialTimeout` to `RetryConfig`, with defaults for RPC and dial operations. - Updated `retryWithBackups` and `dialWithRetry` to use `context.WithTimeout`, enforcing timeouts for RPC calls and connections. Context Propagation: - All MultiClient methods (SendTransaction, CallContract, etc.) now pass context.Context through to support cancellation and timeouts consistently. Test Improvements: - Added `TestMultiClient_dialWithRetry` to cover dial timeouts and failure scenarios. - Expanded `TestMultiClient_retryWithBackups` to simulate timeouts and long-running operations using context.
1 parent 995255c commit b3fec25

File tree

3 files changed

+220
-65
lines changed

3 files changed

+220
-65
lines changed

.changeset/tidy-items-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": patch
3+
---
4+
5+
feat(multiclient): adds timeouts to retryWithBackups and dialWithRetry

deployment/multiclient.go

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,31 @@ const (
2424
// Default retry configuration for RPC calls
2525
RPCDefaultRetryAttempts = 1
2626
RPCDefaultRetryDelay = 1000 * time.Millisecond
27+
RPCDefaultRetryTimeout = 10 * time.Second
2728

2829
// Default retry configuration for dialing RPC endpoints
2930
RPCDefaultDialRetryAttempts = 1
3031
RPCDefaultDialRetryDelay = 1000 * time.Millisecond
32+
RPCDefaultDialTimeout = 10 * time.Second
3133
)
3234

3335
type RetryConfig struct {
3436
Attempts uint
3537
Delay time.Duration
38+
Timeout time.Duration
3639
DialAttempts uint
3740
DialDelay time.Duration
41+
DialTimeout time.Duration
3842
}
3943

4044
func defaultRetryConfig() RetryConfig {
4145
return RetryConfig{
4246
Attempts: RPCDefaultRetryAttempts,
4347
Delay: RPCDefaultRetryDelay,
48+
Timeout: RPCDefaultRetryTimeout,
4449
DialAttempts: RPCDefaultDialRetryAttempts,
4550
DialDelay: RPCDefaultDialRetryDelay,
51+
DialTimeout: RPCDefaultDialTimeout,
4652
}
4753
}
4854

@@ -95,16 +101,16 @@ func NewMultiClient(lggr logger.Logger, rpcsCfg RPCConfig, opts ...func(client *
95101
}
96102

97103
func (mc *MultiClient) SendTransaction(ctx context.Context, tx *types.Transaction) error {
98-
return mc.retryWithBackups("SendTransaction", func(client *ethclient.Client) error {
99-
return client.SendTransaction(ctx, tx)
104+
return mc.retryWithBackups(ctx, "SendTransaction", func(ct context.Context, client *ethclient.Client) error {
105+
return client.SendTransaction(ct, tx)
100106
})
101107
}
102108

103109
func (mc *MultiClient) CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) {
104110
var result []byte
105-
err := mc.retryWithBackups("CallContract", func(client *ethclient.Client) error {
111+
err := mc.retryWithBackups(ctx, "CallContract", func(ct context.Context, client *ethclient.Client) error {
106112
var err error
107-
result, err = client.CallContract(ctx, msg, blockNumber)
113+
result, err = client.CallContract(ct, msg, blockNumber)
108114

109115
return err
110116
})
@@ -114,9 +120,9 @@ func (mc *MultiClient) CallContract(ctx context.Context, msg ethereum.CallMsg, b
114120

115121
func (mc *MultiClient) CallContractAtHash(ctx context.Context, msg ethereum.CallMsg, blockHash common.Hash) ([]byte, error) {
116122
var result []byte
117-
err := mc.retryWithBackups("CallContractAtHash", func(client *ethclient.Client) error {
123+
err := mc.retryWithBackups(ctx, "CallContractAtHash", func(ct context.Context, client *ethclient.Client) error {
118124
var err error
119-
result, err = client.CallContractAtHash(ctx, msg, blockHash)
125+
result, err = client.CallContractAtHash(ct, msg, blockHash)
120126

121127
return err
122128
})
@@ -126,9 +132,9 @@ func (mc *MultiClient) CallContractAtHash(ctx context.Context, msg ethereum.Call
126132

127133
func (mc *MultiClient) CodeAt(ctx context.Context, account common.Address, blockNumber *big.Int) ([]byte, error) {
128134
var code []byte
129-
err := mc.retryWithBackups("CodeAt", func(client *ethclient.Client) error {
135+
err := mc.retryWithBackups(ctx, "CodeAt", func(ct context.Context, client *ethclient.Client) error {
130136
var err error
131-
code, err = client.CodeAt(ctx, account, blockNumber)
137+
code, err = client.CodeAt(ct, account, blockNumber)
132138

133139
return err
134140
})
@@ -138,9 +144,9 @@ func (mc *MultiClient) CodeAt(ctx context.Context, account common.Address, block
138144

139145
func (mc *MultiClient) CodeAtHash(ctx context.Context, account common.Address, blockHash common.Hash) ([]byte, error) {
140146
var code []byte
141-
err := mc.retryWithBackups("CodeAtHash", func(client *ethclient.Client) error {
147+
err := mc.retryWithBackups(ctx, "CodeAtHash", func(ct context.Context, client *ethclient.Client) error {
142148
var err error
143-
code, err = client.CodeAtHash(ctx, account, blockHash)
149+
code, err = client.CodeAtHash(ct, account, blockHash)
144150

145151
return err
146152
})
@@ -150,9 +156,9 @@ func (mc *MultiClient) CodeAtHash(ctx context.Context, account common.Address, b
150156

151157
func (mc *MultiClient) NonceAt(ctx context.Context, account common.Address, block *big.Int) (uint64, error) {
152158
var count uint64
153-
err := mc.retryWithBackups("NonceAt", func(client *ethclient.Client) error {
159+
err := mc.retryWithBackups(ctx, "NonceAt", func(ct context.Context, client *ethclient.Client) error {
154160
var err error
155-
count, err = client.NonceAt(ctx, account, block)
161+
count, err = client.NonceAt(ct, account, block)
156162

157163
return err
158164
})
@@ -162,9 +168,9 @@ func (mc *MultiClient) NonceAt(ctx context.Context, account common.Address, bloc
162168

163169
func (mc *MultiClient) NonceAtHash(ctx context.Context, account common.Address, blockHash common.Hash) (uint64, error) {
164170
var count uint64
165-
err := mc.retryWithBackups("NonceAtHash", func(client *ethclient.Client) error {
171+
err := mc.retryWithBackups(ctx, "NonceAtHash", func(ct context.Context, client *ethclient.Client) error {
166172
var err error
167-
count, err = client.NonceAtHash(ctx, account, blockHash)
173+
count, err = client.NonceAtHash(ct, account, blockHash)
168174

169175
return err
170176
})
@@ -174,9 +180,9 @@ func (mc *MultiClient) NonceAtHash(ctx context.Context, account common.Address,
174180

175181
func (mc *MultiClient) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) {
176182
var header *types.Header
177-
err := mc.retryWithBackups("HeaderByNumber", func(client *ethclient.Client) error {
183+
err := mc.retryWithBackups(ctx, "HeaderByNumber", func(ct context.Context, client *ethclient.Client) error {
178184
var err error
179-
header, err = client.HeaderByNumber(ctx, number)
185+
header, err = client.HeaderByNumber(ct, number)
180186

181187
return err
182188
})
@@ -186,9 +192,9 @@ func (mc *MultiClient) HeaderByNumber(ctx context.Context, number *big.Int) (*ty
186192

187193
func (mc *MultiClient) SuggestGasPrice(ctx context.Context) (*big.Int, error) {
188194
var gasPrice *big.Int
189-
err := mc.retryWithBackups("SuggestGasPrice", func(client *ethclient.Client) error {
195+
err := mc.retryWithBackups(ctx, "SuggestGasPrice", func(ct context.Context, client *ethclient.Client) error {
190196
var err error
191-
gasPrice, err = client.SuggestGasPrice(ctx)
197+
gasPrice, err = client.SuggestGasPrice(ct)
192198

193199
return err
194200
})
@@ -198,9 +204,9 @@ func (mc *MultiClient) SuggestGasPrice(ctx context.Context) (*big.Int, error) {
198204

199205
func (mc *MultiClient) SuggestGasTipCap(ctx context.Context) (*big.Int, error) {
200206
var gasTipCap *big.Int
201-
err := mc.retryWithBackups("SuggestGasTipCap", func(client *ethclient.Client) error {
207+
err := mc.retryWithBackups(ctx, "SuggestGasTipCap", func(ct context.Context, client *ethclient.Client) error {
202208
var err error
203-
gasTipCap, err = client.SuggestGasTipCap(ctx)
209+
gasTipCap, err = client.SuggestGasTipCap(ct)
204210

205211
return err
206212
})
@@ -210,9 +216,9 @@ func (mc *MultiClient) SuggestGasTipCap(ctx context.Context) (*big.Int, error) {
210216

211217
func (mc *MultiClient) PendingCodeAt(ctx context.Context, account common.Address) ([]byte, error) {
212218
var code []byte
213-
err := mc.retryWithBackups("PendingCodeAt", func(client *ethclient.Client) error {
219+
err := mc.retryWithBackups(ctx, "PendingCodeAt", func(ct context.Context, client *ethclient.Client) error {
214220
var err error
215-
code, err = client.PendingCodeAt(ctx, account)
221+
code, err = client.PendingCodeAt(ct, account)
216222

217223
return err
218224
})
@@ -222,9 +228,9 @@ func (mc *MultiClient) PendingCodeAt(ctx context.Context, account common.Address
222228

223229
func (mc *MultiClient) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) {
224230
var count uint64
225-
err := mc.retryWithBackups("PendingNonceAt", func(client *ethclient.Client) error {
231+
err := mc.retryWithBackups(ctx, "PendingNonceAt", func(ct context.Context, client *ethclient.Client) error {
226232
var err error
227-
count, err = client.PendingNonceAt(ctx, account)
233+
count, err = client.PendingNonceAt(ct, account)
228234

229235
return err
230236
})
@@ -234,9 +240,9 @@ func (mc *MultiClient) PendingNonceAt(ctx context.Context, account common.Addres
234240

235241
func (mc *MultiClient) EstimateGas(ctx context.Context, call ethereum.CallMsg) (uint64, error) {
236242
var gas uint64
237-
err := mc.retryWithBackups("EstimateGas", func(client *ethclient.Client) error {
243+
err := mc.retryWithBackups(ctx, "EstimateGas", func(ct context.Context, client *ethclient.Client) error {
238244
var err error
239-
gas, err = client.EstimateGas(ctx, call)
245+
gas, err = client.EstimateGas(ct, call)
240246

241247
return err
242248
})
@@ -246,9 +252,9 @@ func (mc *MultiClient) EstimateGas(ctx context.Context, call ethereum.CallMsg) (
246252

247253
func (mc *MultiClient) BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) {
248254
var balance *big.Int
249-
err := mc.retryWithBackups("BalanceAt", func(client *ethclient.Client) error {
255+
err := mc.retryWithBackups(ctx, "BalanceAt", func(ct context.Context, client *ethclient.Client) error {
250256
var err error
251-
balance, err = client.BalanceAt(ctx, account, blockNumber)
257+
balance, err = client.BalanceAt(ct, account, blockNumber)
252258

253259
return err
254260
})
@@ -258,9 +264,9 @@ func (mc *MultiClient) BalanceAt(ctx context.Context, account common.Address, bl
258264

259265
func (mc *MultiClient) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) {
260266
var logs []types.Log
261-
err := mc.retryWithBackups("FilterLogs", func(client *ethclient.Client) error {
267+
err := mc.retryWithBackups(ctx, "FilterLogs", func(ct context.Context, client *ethclient.Client) error {
262268
var err error
263-
logs, err = client.FilterLogs(ctx, q)
269+
logs, err = client.FilterLogs(ct, q)
264270

265271
return err
266272
})
@@ -270,16 +276,18 @@ func (mc *MultiClient) FilterLogs(ctx context.Context, q ethereum.FilterQuery) (
270276

271277
func (mc *MultiClient) SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error) {
272278
var sub ethereum.Subscription
273-
err := mc.retryWithBackups("SubscribeFilterLogs", func(client *ethclient.Client) error {
279+
err := mc.retryWithBackups(ctx, "SubscribeFilterLogs", func(ct context.Context, client *ethclient.Client) error {
274280
var err error
275-
sub, err = client.SubscribeFilterLogs(ctx, q, ch)
281+
sub, err = client.SubscribeFilterLogs(ct, q, ch)
276282

277283
return err
278284
})
279285

280286
return sub, err
281287
}
282288

289+
// WaitMined waits for a transaction to be mined and returns the receipt.
290+
// Note: retryConfig timeout settings are not used for this operation, a timeout can be set in the context.
283291
func (mc *MultiClient) WaitMined(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) {
284292
mc.lggr.Debugf("Waiting for tx %s to be mined for chain %s", tx.Hash().Hex(), mc.chainName)
285293
// no retries here because we want to wait for the tx to be mined
@@ -318,13 +326,16 @@ func (mc *MultiClient) WaitMined(ctx context.Context, tx *types.Transaction) (*t
318326
}
319327
}
320328

321-
func (mc *MultiClient) retryWithBackups(opName string, op func(*ethclient.Client) error) error {
329+
func (mc *MultiClient) retryWithBackups(ctx context.Context, opName string, op func(context.Context, *ethclient.Client) error) error {
322330
var err error
323331
traceID := uuid.New()
324332
for i, client := range append([]*ethclient.Client{mc.Client}, mc.Backups...) {
325333
retryCount := 0
326334
err2 := retry.Do(func() error {
327-
err = op(client)
335+
timeoutCtx, cancel := ensureTimeout(ctx, mc.RetryConfig.Timeout)
336+
defer cancel()
337+
338+
err = op(timeoutCtx, client)
328339
if err != nil {
329340
mc.lggr.Warnf("traceID %q: chain %q: op: %q: client index %d: failed execution - retryable error '%s'", traceID.String(), mc.chainName, opName, i, MaybeDataErr(err))
330341
return err
@@ -356,9 +367,12 @@ func (mc *MultiClient) dialWithRetry(rpc RPC, lggr logger.Logger) (*ethclient.Cl
356367
var client *ethclient.Client
357368
retryCount := 0
358369
err = retry.Do(func() error {
370+
ctx, cancel := context.WithTimeout(context.Background(), mc.RetryConfig.DialTimeout)
371+
defer cancel()
372+
359373
var err2 error
360374
mc.lggr.Debugf("traceID %q: chain %q: rpc: %q: dialing endpoint '%s'", traceID.String(), mc.chainName, rpc.Name, endpoint)
361-
client, err2 = ethclient.Dial(endpoint)
375+
client, err2 = ethclient.DialContext(ctx, endpoint)
362376
if err2 != nil {
363377
lggr.Warnf("traceID %q: chain %q: rpc: %q: dialing failed - retryable error: %s: %v", traceID.String(), mc.chainName, rpc.Name, endpoint, err2)
364378
return err2
@@ -377,3 +391,17 @@ func (mc *MultiClient) dialWithRetry(rpc RPC, lggr logger.Logger) (*ethclient.Cl
377391

378392
return client, nil
379393
}
394+
395+
// ensureTimeout checks if the parent context has a deadline.
396+
// If it does, it returns a new cancelable context using the parent's deadline.
397+
// If it doesn't, it creates a new context with the specified timeout.
398+
func ensureTimeout(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
399+
// check if the parent context already has a deadline
400+
if _, hasDeadline := parent.Deadline(); hasDeadline {
401+
// derive a new cancelable context from the parent context with the same deadline
402+
return context.WithCancel(parent)
403+
}
404+
405+
// create a new context with the specified timeout
406+
return context.WithTimeout(parent, timeout)
407+
}

0 commit comments

Comments
 (0)