From f7d79dea28afced649439fc9122b9f916c9326a2 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 12 Mar 2026 12:27:43 -0700 Subject: [PATCH] fix(sendRawTransaction): Adding retry logic --- src/raw-transactions.js | 76 +++++++++++++++++++++++++----- test/unit/raw-transactions.unit.js | 71 ++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 test/unit/raw-transactions.unit.js diff --git a/src/raw-transactions.js b/src/raw-transactions.js index 1585570..087bb2f 100644 --- a/src/raw-transactions.js +++ b/src/raw-transactions.js @@ -15,6 +15,46 @@ class RawTransactions { // Use the shared axios instance if provided, otherwise fall back to axios this.axios = config.axios || axios + + // Retry configuration for transient network failures during broadcast. + this.maxBroadcastRetries = 2 + this.broadcastRetryDelayMs = 250 + } + + _sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + _isTransientNetworkError (error) { + if (!error) return false + + const msg = String(error.message || '').toLowerCase() + const causeMsg = String(error?.cause?.message || '').toLowerCase() + const stack = String(error.stack || '').toLowerCase() + const code = String(error.code || error?.cause?.code || '').toUpperCase() + + if (['ECONNRESET', 'EPIPE', 'ETIMEDOUT'].includes(code)) return true + if (msg.includes('socket hang up')) return true + if (causeMsg.includes('socket hang up')) return true + if (stack.includes('socket hang up')) return true + + return false + } + + async _postSendRawTransaction (hexes) { + const options = { + method: 'POST', + url: `${this.restURL}full-node/rawtransactions/sendRawTransaction`, + data: { + hexes + }, + headers: { + ...this.axiosOptions.headers, + Connection: 'close' + } + } + + return this.axios(options) } /** @@ -448,7 +488,13 @@ class RawTransactions { if (typeof hex === 'string') { const response = await this.axios.get( `${this.restURL}full-node/rawtransactions/sendRawTransaction/${hex}`, - this.axiosOptions + { + ...this.axiosOptions, + headers: { + ...this.axiosOptions.headers, + Connection: 'close' + } + } ) if (response.data === '66: insufficient priority') { @@ -463,17 +509,25 @@ class RawTransactions { // Array input } else if (Array.isArray(hex)) { - const options = { - method: 'POST', - url: `${this.restURL}full-node/rawtransactions/sendRawTransaction`, - data: { - hexes: hex - }, - headers: this.axiosOptions.headers - } - const response = await this.axios(options) + let lastErr - return response.data + for (let attempt = 0; attempt <= this.maxBroadcastRetries; attempt++) { + try { + const response = await this._postSendRawTransaction(hex) + return response.data + } catch (err) { + lastErr = err + + const isLastAttempt = attempt >= this.maxBroadcastRetries + const shouldRetry = this._isTransientNetworkError(err) && !isLastAttempt + if (!shouldRetry) throw err + + const delay = this.broadcastRetryDelayMs * Math.pow(2, attempt) + await this._sleep(delay) + } + } + + throw lastErr } throw new Error('Input hex must be a string or array of strings.') diff --git a/test/unit/raw-transactions.unit.js b/test/unit/raw-transactions.unit.js new file mode 100644 index 0000000..3f50c67 --- /dev/null +++ b/test/unit/raw-transactions.unit.js @@ -0,0 +1,71 @@ +/* + Focused unit tests for retry behavior in raw-transactions.js. +*/ + +import assert from 'assert' +import sinon from 'sinon' + +import RawTransactions from '../../src/raw-transactions.js' + +describe('#RawTransactions Retry Logic', () => { + afterEach(() => sinon.restore()) + + it('retries once on ECONNRESET and succeeds', async () => { + const axiosStub = sinon.stub() + axiosStub.onCall(0).rejects(Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })) + axiosStub.onCall(1).resolves({ data: ['txid-123'] }) + + const uut = new RawTransactions({ + restURL: 'http://localhost:5942/v6/', + authToken: '', + axios: axiosStub + }) + uut.broadcastRetryDelayMs = 0 + + const result = await uut.sendRawTransaction(['abcd']) + + assert.deepStrictEqual(result, ['txid-123']) + assert.equal(axiosStub.callCount, 2) + assert.equal(axiosStub.getCall(0).args[0].headers.Connection, 'close') + assert.equal(axiosStub.getCall(1).args[0].headers.Connection, 'close') + }) + + it('does not retry on non-transient errors', async () => { + const axiosStub = sinon.stub() + axiosStub.rejects(new Error('RPC validation error')) + + const uut = new RawTransactions({ + restURL: 'http://localhost:5942/v6/', + authToken: '', + axios: axiosStub + }) + uut.broadcastRetryDelayMs = 0 + + await assert.rejects( + uut.sendRawTransaction(['abcd']), + /RPC validation error/ + ) + assert.equal(axiosStub.callCount, 1) + }) + + it('enforces retry cap for repeated transient failures', async () => { + const axiosStub = sinon.stub() + axiosStub.rejects(Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })) + + const uut = new RawTransactions({ + restURL: 'http://localhost:5942/v6/', + authToken: '', + axios: axiosStub + }) + uut.broadcastRetryDelayMs = 0 + uut.maxBroadcastRetries = 3 + + await assert.rejects( + uut.sendRawTransaction(['abcd']), + /socket hang up/ + ) + + // 1 initial attempt + 2 retries. + assert.equal(axiosStub.callCount, 4) + }) +})