Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Retry #2264

Closed
ronag opened this issue Sep 13, 2023 · 0 comments · Fixed by #2281
Closed

Retry #2264

ronag opened this issue Sep 13, 2023 · 0 comments · Fixed by #2281
Labels
enhancement New feature or request

Comments

@ronag
Copy link
Member

ronag commented Sep 13, 2023

Similar to #2256

Sharing some internal code we have been using in case someone wants to work on it and add it as a util in undici.

const assert = require('node:assert')
const stream = require('node:stream')
const { isReadableNodeStream } = require('../stream')
const { parseHeaders } = require('../http')
const createError = require('http-errors')

function parseRange(range) {
  const m = range ? range.match(/^bytes=(\d+)-(\d+)?$/) : null
  return m ? { start: parseInt(m[1]), end: m[2] ? parseInt(m[2]) : null } : null
}

module.exports = class RetryHandler {
  /** @type {Object} */ opts
  /** @type {Object} */ dispatcher
  /** @type {Error | undefined} */ retryError
  /** @type {Number} */ retryCount = 0
  /** @type {Number} */ retryMax
  /** @type {Array<Number>} */ retryCode
  /** @type {Array<String>} */ retryMessage
  /** @type {Array<Number>} */ retryStatus
  /** @type {Array<String>} */ retryMethod
  /** @type {Boolean} */ idempotent
  /** @type {Number} */ position = 0
  /** @type {NodeJS.Timeout | null} */ timeout
  /** @type {{start: Number, end: Number | null} | null} */ range

  constructor (opts, { dispatcher, handler }) {
    const range = opts.headers?.range ?? opts.headers?.range

    this.dispatcher = dispatcher
    this.handler = handler
    this.opts = opts
    this.statusCode = 0
    this.idempotent = opts.idempotent

    const {
      count: retryMax = 8,
      method: retryMethod = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'PATCH'],
      status: retryStatus = [420, 429, 502, 503, 504],
      code: retryCode = [
        'ECONNRESET',
        'ECONNREFUSED',
        'ENOTFOUND',
        'ENETDOWN',
        'ENETUNREACH',
        'EHOSTDOWN',
        'EHOSTUNREACH',
        'EPIPE',
      ],
      message: retryMessage = ['other side closed'],
    } = opts.retry ?? {}

    this.retryMax = retryMax
    this.retryCount = retryCode
    this.retryMessage = retryMessage
    this.retryStatus = retryStatus
    this.retryMethod = retryMethod

    this.range = opts.method === 'GET'
      ? range
        ? parseRange(range)
        : { start: 0, end: null }
      : null
  }

  onConnect(abort) {
    this.handler.onConnect(err => {
      if (this.timeout) {
        clearTimeout(this.timeout)
      }
      abort(err)
    })
  }

  onHeaders(statusCode, rawHeaders, resume, statusMessage) {
    if (statusCode >= 300) {
      throw createError(statusCode, { headers: parseHeaders(rawHeaders) })
    }

    let contentLength
    for (let i = 0; i < rawHeaders.length; i += 2) {
      const key = rawHeaders[i].toString()
      if (key.length === 'content-length'.length && key.toLowerCase() === 'content-length') {
        contentLength = rawHeaders[key]
      }
    }

    if (statusCode === 200 && this.range?.end === null && contentLength) {
      this.range.end = Number(contentLength)
      assert(Number.isFinite(this.range.end), 'invalid content-length')
    }

    if (this.statusCode) {
      assert(this.retryError)

      if (statusCode === 200) {
        // TODO (feat): Resume 200 if distance is not too far?
        throw this.retryError
      } else if (statusCode === 206) {
        // TODO (fix): Check content-range.
        return true // TODO (fix): return false if body is full...
      } else {
        throw this.retryError
      }
    } else {
      this.statusCode = statusCode
      return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
    }
  }

  onData(chunk) {
    this.position += chunk.length
    return this.handler.onData(chunk)
  }

  onComplete(rawTrailers) {
    return this.handler.onComplete(rawTrailers)
  }

  onError(err) {
    if (this.statusCode) {
      if (this.range && this.retryCount < this.retryMax && this.opts.methid === 'GET') {
        // this.logger?.warn({ err, retryCount: this.retryCount }, 'upstream response retrying')

        this.retryError = err
        this.retryCount += 1

        this.dispatcher.dispatch(
          {
            ...this.opts,
            headers: {
              ...this.opts.headers,
              range: `bytes=${this.range.start + this.position}-${
                this.range.end ? this.range.end : ''
              }`,
            },
          },
          this,
        )
      } else {
        return this.handler.onError(err)
      }
    } else if (
      err.name !== 'AbortError' &&
      this.retryCount < this.retryMax &&
      (this.opts.body == null ||
        typeof this.opts.body === 'string' ||
        Buffer.isBuffer(this.opts.body) ||
        // @ts-ignore: isDisturbed is not in typedefs
        (isReadableNodeStream(this.opts.body) && !stream.isDisturbed(this.opts.body))) &&
      (this.idempotent || this.retryMethod.includes(this.opts.method)) &&
      (this.retryCode.includes(err.code) ||
        this.retryMessage.includes(err.message) ||
        this.retryStatus.includes(err.statusCode))
    ) {
      const delay =
        parseInt(err.headers?.['Retry-After']) * 1e3 ||
        Math.min(10e3, this.retryCount * 1e3 + 1e3)

      // this.logger?.warn({ err, retryCount: this.retryCount, delay }, 'upstream request retrying')

      this.retryError = err
      this.retryCount += 1

      this.timeout = setTimeout(() => {
        this.timeout = null
        this.dispatcher.dispatch(this.opts, this)
      }, delay).unref()
    } else {
      // this.logger?.error({ err }, 'upstream request failed')
      this.handler.onError(err)
    }
  }
}
@ronag ronag added the enhancement New feature or request label Sep 13, 2023
ronag pushed a commit that referenced this issue Nov 13, 2023
* feat: initial implementation

* feat: handle simple scenario

* feat: enhance default retry

* feat: enhance err

* feat: add support for retry-after header

* feat: add support for weak etag check

* ts: adjust types

* refactor: reduce magic

* docs: add RetryAfter documentation

* refactor: small adjustments

* refactor: apply review suggestions

* refactor: apply review

* feat: set retry async

* refactor: apply reviews
metcoder95 added a commit that referenced this issue Nov 22, 2023
* feat: initial implementation

* feat: handle simple scenario

* feat: enhance default retry

* feat: enhance err

* feat: add support for retry-after header

* feat: add support for weak etag check

* ts: adjust types

* refactor: reduce magic

* docs: add RetryAfter documentation

* refactor: small adjustments

* refactor: apply review suggestions

* refactor: apply review

* feat: set retry async

* refactor: apply reviews
crysmags pushed a commit to crysmags/undici that referenced this issue Feb 27, 2024
* feat: initial implementation

* feat: handle simple scenario

* feat: enhance default retry

* feat: enhance err

* feat: add support for retry-after header

* feat: add support for weak etag check

* ts: adjust types

* refactor: reduce magic

* docs: add RetryAfter documentation

* refactor: small adjustments

* refactor: apply review suggestions

* refactor: apply review

* feat: set retry async

* refactor: apply reviews
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant