diff --git a/package.json b/package.json index f3ef3791e0407f4af32f10553a0178027b51f68e..284d9ed4a21b3c3e73bac0af6af0f3c731b4e116 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "detect-node": "^2.0.3", "ecurve": "^1.0.5", "lodash": "^4.16.4", + "retry": "^0.12.0", "secure-random": "^1.1.1", "ws": "^3.3.2" }, diff --git a/src/api/transports/base.js b/src/api/transports/base.js index 860c2e9a43537247eb496c5df553c356971bff24..15bb7afb5d9b79c7be6a02360bcad1db3adb093a 100644 --- a/src/api/transports/base.js +++ b/src/api/transports/base.js @@ -27,7 +27,6 @@ export default class Transport extends EventEmitter { send() {} start() {} stop() {} - } Promise.promisifyAll(Transport.prototype); diff --git a/src/api/transports/http.js b/src/api/transports/http.js index 70c9139daec4b1331696f4ffa28ae80ecd05b0a9..5d5bce3ca7f877dc5c26c5c42b7f839d7a24d965 100644 --- a/src/api/transports/http.js +++ b/src/api/transports/http.js @@ -1,5 +1,6 @@ import fetch from 'cross-fetch'; import newDebug from 'debug'; +import retry from 'retry'; import Transport from './base'; const debug = newDebug('steem:http'); @@ -13,9 +14,9 @@ class RPCError extends Error { } } -export function jsonRpc(uri, {method, id, params}) { +export function jsonRpc(uri, {method, id, params, fetchMethod}) { const payload = {id, jsonrpc: '2.0', method, params}; - return fetch(uri, { + return (fetchMethod || fetch)(uri, { body: JSON.stringify(payload), method: 'post', mode: 'cors', @@ -42,12 +43,47 @@ export function jsonRpc(uri, {method, id, params}) { export default class HttpTransport extends Transport { send(api, data, callback) { if (this.options.useAppbaseApi) { - api = 'condenser_api'; + api = 'condenser_api'; } debug('Steem::send', api, data); const id = data.id || this.id++; const params = [api, data.method, data.params]; - jsonRpc(this.options.uri, {method: 'call', id, params}) - .then(res => { callback(null, res) }, err => { callback(err) }) + const retriable = this.retriable(api, data); + const fetchMethod = this.options.fetchMethod; + retriable.attempt((currentAttempt) => { + jsonRpc(this.options.uri, { method: 'call', id, params, fetchMethod }).then( + res => { callback(null, res); }, + err => { + if (retriable.retry(err)) return; + callback(retriable.mainError()); + } + ); + }); + } + + get nonRetriableOperations() { + return this.options.nonRetriableOperations || [ + 'broadcast_transaction', + 'broadcast_transaction_with_callback', + 'broadcast_transaction_synchronous', + 'broadcast_block', + ]; + } + + // An object which can be used to track retries. + retriable(api, data) { + if (this.nonRetriableOperations.some((o) => o === data.method)) { + // Do not retry if the operation is non-retriable. + return retry.operation({ retries: 0 }); + } else if (this.options.retry) { + // If `this.options.retry` is a map of options, use it. If + // `this.options.retry` is `true`, use default options. + return (Object(this.options.retry) === this.options.retry) ? + retry.operation(this.options.retry) : + retry.operation(); + } else { + // Otherwise, don't retry. + return retry.operation({ retries: 0 }); + } } } diff --git a/test/api.test.js b/test/api.test.js index 833d9df7a7ebf6b1af53b2dd5e2c8dbd80fc3f2e..6c1b9c0f9b1b466dfb652f5e7fa7645e0bcdbdbb 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -150,4 +150,180 @@ describe('steem.api:', function () { }); }); + describe('with retry', () => { + afterEach(() => { + // NOTE: We should reset `steem.api.options` after *every* test. + steem.api.setOptions({ + url: 'https://api.steemit.com', + retry: null, + fetchMethod: null, + }); + }); + + it('works by default', async function() { + let attempts = 0; + steem.api.setOptions({ + url: 'https://api.steemit.com', + fetchMethod: (uri, req) => new Promise((res, rej) => { + const data = JSON.parse(req.body); + res({ + ok: true, + json: () => Promise.resolve({ + jsonrpc: '2.0', + id: data.id, + result: ['ned'], + }), + }); + attempts++; + }), + }); + const result = await steem.api.getFollowersAsync('ned', 0, 'blog', 5) + assert.equal(attempts, 1); + assert.deepEqual(result, ['ned']); + }); + + it('does not retry by default', async() => { + let attempts = 0; + steem.api.setOptions({ + url: 'https://api.steemit.com', + fetchMethod: (uri, req) => new Promise((res, rej) => { + rej(new Error('Bad request')); + attempts++; + }), + }); + + let result; + let errored = false; + try { + result = await steem.api.getFollowersAsync('ned', 0, 'blog', 5) + } catch (e) { + errored = true; + } + assert.equal(attempts, 1); + assert.equal(errored, true); + }); + + it('works with retry passed as a boolean', async() => { + let attempts = 0; + steem.api.setOptions({ + url: 'https://api.steemit.com', + fetchMethod: (uri, req) => new Promise((res, rej) => { + const data = JSON.parse(req.body); + res({ + ok: true, + json: () => Promise.resolve({ + jsonrpc: '2.0', + id: data.id, + result: ['ned'], + }), + }); + attempts++; + }), + }); + + const result = await steem.api.getFollowersAsync('ned', 0, 'blog', 5) + assert.equal(attempts, 1); + assert.deepEqual(result, ['ned']); + }); + + it('retries with retry passed as a boolean', async() => { + let attempts = 0; + steem.api.setOptions({ + url: 'https://api.steemit.com', + retry: true, + fetchMethod: (uri, req) => new Promise((res, rej) => { + if (attempts < 1) { + rej(new Error('Bad request')); + } else { + const data = JSON.parse(req.body); + res({ + ok: true, + json: () => Promise.resolve({ + jsonrpc: '2.0', + id: data.id, + result: ['ned'], + }), + }); + } + attempts++; + }), + }); + + let result; + let errored = false; + try { + result = await steem.api.getFollowersAsync('ned', 0, 'blog', 5); + } catch (e) { + errored = true; + } + assert.equal(attempts, 2); + assert.equal(errored, false); + assert.deepEqual(result, ['ned']); + }); + + it('works with retry passed as an object', async() => { + steem.api.setOptions({ + url: 'https://api.steemit.com', + retry: { + retries: 3, + minTimeout: 1, // 1ms + }, + fetchMethod: (uri, req) => new Promise((res, rej) => { + const data = JSON.parse(req.body); + res({ + ok: 'true', + json: () => Promise.resolve({ + jsonrpc: '2.0', + id: data.id, + result: ['ned'], + }), + }); + }), + }); + + const result = await steem.api.getFollowersAsync('ned', 0, 'blog', 5); + assert.deepEqual(result, ['ned']); + }); + + it('retries with retry passed as an object', async() => { + let attempts = 0; + steem.api.setOptions({ + url: 'https://api.steemit.com', + retry: { + retries: 3, + minTimeout: 1, + }, + fetchMethod: (uri, req) => new Promise((res, rej) => { + if (attempts < 1) { + rej(new Error('Bad request')); + } else { + const data = JSON.parse(req.body); + res({ + ok: true, + json: () => Promise.resolve({ + jsonrpc: '2.0', + id: data.id, + result: ['ned'], + }), + }); + } + attempts++; + }), + }); + + let result; + let errored = false; + try { + result = await steem.api.getFollowersAsync('ned', 0, 'blog', 5); + } catch (e) { + errored = true; + } + assert.equal(attempts, 2); + assert.equal(errored, false); + assert.deepEqual(result, ['ned']); + }); + + it('does not retry non-retriable operations'); + }); + });