import request from 'request' import retry from 'retry' import ExtendableError from 'es6-error' import RateLimit from '../rate-limit' import pkg from '../../package.json' const debug = require('debug')('graphbrainz:api/client') // If the `request` callback returns an error, it indicates a failure at a lower // level than the HTTP response itself. If it's any of the following error // codes, we should retry. const RETRY_CODES = { ECONNRESET: true, ENOTFOUND: true, ESOCKETTIMEDOUT: true, ETIMEDOUT: true, ECONNREFUSED: true, EHOSTUNREACH: true, EPIPE: true, EAI_AGAIN: true } export class ClientError extends ExtendableError { constructor (message, statusCode) { super(message) this.statusCode = statusCode } } export default class Client { constructor ({ baseURL = process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/', userAgent = `${pkg.name}/${pkg.version} ` + `( ${pkg.homepage || pkg.author.url || pkg.author.email} )`, extraHeaders = {}, errorClass = ClientError, timeout = 60000, // MusicBrainz API requests are limited to an *average* of 1 req/sec. // That means if, for example, we only need to make a few API requests to // fulfill a query, we might as well make them all at once - as long as // we then wait a few seconds before making more. In practice this can // seemingly be set to about 5 requests every 5 seconds before we're // considered to exceed the rate limit. limit = 1, period = 1000, concurrency = 10, retries = 10, // It's OK for `retryDelayMin` to be less than one second, even 0, because // `RateLimit` will already make sure we don't exceed the API rate limit. // We're not doing exponential backoff because it will help with being // rate limited, but rather to be chill in case MusicBrainz is returning // some other error or our network is failing. retryDelayMin = 100, retryDelayMax = 60000, randomizeRetry = true } = {}) { this.baseURL = baseURL this.userAgent = userAgent this.extraHeaders = extraHeaders this.errorClass = errorClass this.timeout = timeout this.limiter = new RateLimit({ limit, period, concurrency }) this.retryOptions = { retries, minTimeout: retryDelayMin, maxTimeout: retryDelayMax, randomize: randomizeRetry } } /** * Determine if we should retry the request based on the given error. * Retry any 5XX response from MusicBrainz, as well as any error in * `RETRY_CODES`. */ shouldRetry (err) { if (err instanceof this.errorClass) { return err.statusCode >= 500 && err.statusCode < 600 } return RETRY_CODES[err.code] || false } parseErrorMessage (response, body) { return typeof body === 'string' && body ? body : `${response.statusCode}` } /** * Send a request without any retrying or rate limiting. * Use `get` instead. */ _get (path, options = {}, info = {}) { return new Promise((resolve, reject) => { options = { baseUrl: this.baseURL, url: path, gzip: true, timeout: this.timeout, ...options, headers: { 'User-Agent': this.userAgent, ...this.extraHeaders, ...options.headers } } debug(`Sending request. url=${this.baseURL}${path} attempt=${info.currentAttempt}`) request(options, (err, response, body) => { if (err) { debug(`Error: “${err}” url=${this.baseURL}${path}`) reject(err) } else if (response.statusCode >= 400) { const message = this.parseErrorMessage(response, body) debug(`Error: “${message}” url=${this.baseURL}${path}`) const ClientError = this.errorClass reject(new ClientError(message, response.statusCode)) } else if (options.method === 'HEAD') { resolve(response.headers) } else { resolve(body) } }) }) } /** * Send a request with retrying and rate limiting. */ get (path, options = {}) { return new Promise((resolve, reject) => { const fn = this._get.bind(this) const operation = retry.operation(this.retryOptions) operation.attempt(currentAttempt => { // This will increase the priority in our `RateLimit` queue for each // retry, so that newer requests don't delay this one further. const priority = currentAttempt this.limiter.enqueue(fn, [path, options, { currentAttempt }], priority) .then(resolve) .catch(err => { if (!this.shouldRetry(err) || !operation.retry(err)) { reject(operation.mainError() || err) } }) }) }) } }