@psf/bch-js 7.1.13 → 7.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psf/bch-js",
3
- "version": "7.1.13",
3
+ "version": "7.1.14",
4
4
  "type": "module",
5
5
  "description": "A JavaScript library for working with Bitcoin Cash and SLP Tokens",
6
6
  "author": "Chris Troutner <chris.troutner@gmail.com>",
@@ -15,6 +15,46 @@ class RawTransactions {
15
15
 
16
16
  // Use the shared axios instance if provided, otherwise fall back to axios
17
17
  this.axios = config.axios || axios
18
+
19
+ // Retry configuration for transient network failures during broadcast.
20
+ this.maxBroadcastRetries = 2
21
+ this.broadcastRetryDelayMs = 250
22
+ }
23
+
24
+ _sleep (ms) {
25
+ return new Promise(resolve => setTimeout(resolve, ms))
26
+ }
27
+
28
+ _isTransientNetworkError (error) {
29
+ if (!error) return false
30
+
31
+ const msg = String(error.message || '').toLowerCase()
32
+ const causeMsg = String(error?.cause?.message || '').toLowerCase()
33
+ const stack = String(error.stack || '').toLowerCase()
34
+ const code = String(error.code || error?.cause?.code || '').toUpperCase()
35
+
36
+ if (['ECONNRESET', 'EPIPE', 'ETIMEDOUT'].includes(code)) return true
37
+ if (msg.includes('socket hang up')) return true
38
+ if (causeMsg.includes('socket hang up')) return true
39
+ if (stack.includes('socket hang up')) return true
40
+
41
+ return false
42
+ }
43
+
44
+ async _postSendRawTransaction (hexes) {
45
+ const options = {
46
+ method: 'POST',
47
+ url: `${this.restURL}full-node/rawtransactions/sendRawTransaction`,
48
+ data: {
49
+ hexes
50
+ },
51
+ headers: {
52
+ ...this.axiosOptions.headers,
53
+ Connection: 'close'
54
+ }
55
+ }
56
+
57
+ return this.axios(options)
18
58
  }
19
59
 
20
60
  /**
@@ -448,7 +488,13 @@ class RawTransactions {
448
488
  if (typeof hex === 'string') {
449
489
  const response = await this.axios.get(
450
490
  `${this.restURL}full-node/rawtransactions/sendRawTransaction/${hex}`,
451
- this.axiosOptions
491
+ {
492
+ ...this.axiosOptions,
493
+ headers: {
494
+ ...this.axiosOptions.headers,
495
+ Connection: 'close'
496
+ }
497
+ }
452
498
  )
453
499
 
454
500
  if (response.data === '66: insufficient priority') {
@@ -463,17 +509,25 @@ class RawTransactions {
463
509
 
464
510
  // Array input
465
511
  } else if (Array.isArray(hex)) {
466
- const options = {
467
- method: 'POST',
468
- url: `${this.restURL}full-node/rawtransactions/sendRawTransaction`,
469
- data: {
470
- hexes: hex
471
- },
472
- headers: this.axiosOptions.headers
512
+ let lastErr
513
+
514
+ for (let attempt = 0; attempt <= this.maxBroadcastRetries; attempt++) {
515
+ try {
516
+ const response = await this._postSendRawTransaction(hex)
517
+ return response.data
518
+ } catch (err) {
519
+ lastErr = err
520
+
521
+ const isLastAttempt = attempt >= this.maxBroadcastRetries
522
+ const shouldRetry = this._isTransientNetworkError(err) && !isLastAttempt
523
+ if (!shouldRetry) throw err
524
+
525
+ const delay = this.broadcastRetryDelayMs * Math.pow(2, attempt)
526
+ await this._sleep(delay)
527
+ }
473
528
  }
474
- const response = await this.axios(options)
475
529
 
476
- return response.data
530
+ throw lastErr
477
531
  }
478
532
 
479
533
  throw new Error('Input hex must be a string or array of strings.')
@@ -0,0 +1,71 @@
1
+ /*
2
+ Focused unit tests for retry behavior in raw-transactions.js.
3
+ */
4
+
5
+ import assert from 'assert'
6
+ import sinon from 'sinon'
7
+
8
+ import RawTransactions from '../../src/raw-transactions.js'
9
+
10
+ describe('#RawTransactions Retry Logic', () => {
11
+ afterEach(() => sinon.restore())
12
+
13
+ it('retries once on ECONNRESET and succeeds', async () => {
14
+ const axiosStub = sinon.stub()
15
+ axiosStub.onCall(0).rejects(Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }))
16
+ axiosStub.onCall(1).resolves({ data: ['txid-123'] })
17
+
18
+ const uut = new RawTransactions({
19
+ restURL: 'http://localhost:5942/v6/',
20
+ authToken: '',
21
+ axios: axiosStub
22
+ })
23
+ uut.broadcastRetryDelayMs = 0
24
+
25
+ const result = await uut.sendRawTransaction(['abcd'])
26
+
27
+ assert.deepStrictEqual(result, ['txid-123'])
28
+ assert.equal(axiosStub.callCount, 2)
29
+ assert.equal(axiosStub.getCall(0).args[0].headers.Connection, 'close')
30
+ assert.equal(axiosStub.getCall(1).args[0].headers.Connection, 'close')
31
+ })
32
+
33
+ it('does not retry on non-transient errors', async () => {
34
+ const axiosStub = sinon.stub()
35
+ axiosStub.rejects(new Error('RPC validation error'))
36
+
37
+ const uut = new RawTransactions({
38
+ restURL: 'http://localhost:5942/v6/',
39
+ authToken: '',
40
+ axios: axiosStub
41
+ })
42
+ uut.broadcastRetryDelayMs = 0
43
+
44
+ await assert.rejects(
45
+ uut.sendRawTransaction(['abcd']),
46
+ /RPC validation error/
47
+ )
48
+ assert.equal(axiosStub.callCount, 1)
49
+ })
50
+
51
+ it('enforces retry cap for repeated transient failures', async () => {
52
+ const axiosStub = sinon.stub()
53
+ axiosStub.rejects(Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }))
54
+
55
+ const uut = new RawTransactions({
56
+ restURL: 'http://localhost:5942/v6/',
57
+ authToken: '',
58
+ axios: axiosStub
59
+ })
60
+ uut.broadcastRetryDelayMs = 0
61
+ uut.maxBroadcastRetries = 3
62
+
63
+ await assert.rejects(
64
+ uut.sendRawTransaction(['abcd']),
65
+ /socket hang up/
66
+ )
67
+
68
+ // 1 initial attempt + 2 retries.
69
+ assert.equal(axiosStub.callCount, 4)
70
+ })
71
+ })