npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

retryyy

v1.0.0

Published

A better way to retry async operations in TypeScript/JavaScript.

Downloads

25

Readme

retryyy

A better way to retry async operations in TypeScript/JavaScript.


Highlights

  • 🪄 Easy: Handy defaults and easily configurable.
  • 🪶 Lightweight: Only 619 bytes core (417B gzipped). Get all the goodies for 2.6kb (1.3kB gzipped).
  • 📦 Complete: Includes circuit breaker, exponential backoff, timeout, jitter, logging, branded errors, and more.
  • 🌟 Modern: Leverage modern standards like AbortSignal, AggregateError, decorators, and ESM.
  • 🧘 Simple: More than a library, retryyy is a pattern for retry control-flow.
  • 🔗 Composable: Policies are functions that can be chained together like middlewares.
  • 🔐 Type-safe: Safely wrap your existing TypeScript functions in retry logic.

Setup

Install it from npm with your preferred package manager:

pnpm add retryyy
npm install retryyy
yarn add retryyy
bun add retryyy

Usage

import { retryyy } from 'retryyy'

retryyy(async () => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${1}`)
  const user = await res.json()
  console.log(user)
})

It will retry the provided async functions using the Default policy.

Customizing the default policy

An object can be passed as a second argument to retryyy() to customize the behavior of the default policy.

import { retryyy } from 'retryyy'

retryyy(
  async () => {
    // do stuff...
  },
  {
    timeout: 10_000, // Shorter timeout; 10 seconds.
  },
)

Options

| Option | Description | Default | | -------------- | --------------------------------------------------- | --------------- | | fastTrack | If true, runs the first re-attempt immediately. | false | | initialDelay | The initial delay in milliseconds. | 150ms | | logError | Logger function to use when giving up on retries. | console.error | | logWarn | Logger function to use when retrying. | console.warn | | maxAttempts | The maximum number of attempts to make. | 10 | | maxDelay | The maximum delay between attempts in milliseconds. | 30 seconds | | timeout | The time in milliseconds after which to give up. | 30 seconds | | next | Chain another policy after the default ones. | undefined |

Retry indefinitely

import { retryyy } from 'retryyy'

retryyy(
  async () => {
    // do stuff...
  },
  {
    maxAttempts: Infinity,
    timeout: Infinity,
  },
)

Disable logs

import { retryyy } from 'retryyy'

retryyy(
  async () => {
    // do stuff...
  },
  {
    logError: false,
    logWarn: false,
  },
)

Wrapping functions

While retryyy() is a handy option, the wrap() API allows for better composition and cleaner code by taking existing functions and creating new ones with retry logic attached to them. Its signature is similar but, instead of executing the passed function immediately, it returns a new function.

import { wrap } from 'retryyy'

type UserShape = { id: number; name: string }

async function _fetchUser(id: number) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
  return (await res.json()) as UserShape
}

export const fetchUser = wrap(_fetchUser, { timeout: 10_000 })

const user = await fetchUser(1)
console.log(user)

Wrapping class methods

Class methods can be decorated with Retryyy (uppercase initial):

import { Retryyy } from 'retryyy'

type UserShape = { id: number; name: string }

class UserModel {
  @Retryyy({ timeout: 10_000 })
  async fetchUser(id: number) {
    const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
    return (await res.json()) as UserShape
  }
}

const users = new UserModel()
const user = await users.fetchUser(1)
console.log(user)

@Retryyy decorators use the Stage 3 ECMAScript Decorators spec so TypeScript 5.0 or higher is required.

Alternatively, class field syntax can be used, but be aware of this binding behaviors and the potential performance penalty since the method will be attached to individual instances rather than to the shared prototype.

import { wrap } from 'retryyy'

type UserShape = { id: number; name: string }

class UserModel {
  fetchUser = wrap(async (id: number) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
    return (await res.json()) as UserShape
  })
}

@Retryyy is actually a factory that returns a decorator that can be referenced and applied multiple times:

import { Retryyy } from 'retryyy'

const RetryForever = Retryyy({ maxAttempts: Infinity, timeout: Infinity })

class UserModel {
  @RetryForever
  async fetchUser(id: number) {
    // do stuff...
  }

  @RetryForever
  async deleteUser(id: number) {
    // do stuff...
  }
}

class CartModel {
  @RetryForever
  async clearCart() {
    // do stuff...
  }
}

Custom policies

A policy in retryyy is a function that controls the retry behavior based on the current retry state, returning a delay in milliseconds to wait before the next attempt or throwing an error to give up on the operation.

import type { RetryPolicy } from 'retryyy'
import { retryyy } from 'retryyy'

const customPolicy: RetryPolicy = (state) => {
  if (state.attempt > 3 || state.elapsed > 5_000) {
    throw state.error
  }

  return state.attempt * 1000
}

type UserShape = { id: number; name: string }

retryyy(async () => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${1}`)
  const user = await res.json()
  console.log(user)
}, customPolicy)

This example implements a simple linear backoff, stopping after 3 retries or 5 seconds total, whatever happens first.

Composing policies

Policies in retryyy can be composed using the join() function, allowing to create complex retry strategies from simpler building blocks.

import type { RetryPolicy } from 'retryyy'
import { join, retryyy } from 'retryyy'

/* 1 */
const breaker: RetryPolicy = (state, next) => {
  if (state.attempt > 5) {
    throw state.error
  }

  return next(state)
}

/* 3 */
const jitter: RetryPolicy = (state, next) => {
  const delay = next(state)
  return delay + Math.random() * 1000
}

/* 2 */
const backoff: RetryPolicy = (state) => {
  return Math.pow(2, state.attempt - 1) * 1000
}

const composedPolicy = join(breaker, jitter, backoff)

type UserShape = { id: number; name: string }

retryyy(async () => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${1}`)
  const user = await res.json()
  console.log(user)
}, composedPolicy)

Policies are executed left to right, each able to throw an error, return a delay, or call the next policy. This composition allows for flexible and powerful retry strategies tailored to specific needs.

In this example:

  1. breaker: Bails out from the operation after 5 attempts.
  2. backoff: Exponential backoff starting at 1 second.
  3. jitter: Adds some random time to the delay returned by the backoff (next) policy to prevent synchronized retries.

Note that the Default policy does exactly that.

Advanced

Give up after certain errors

import { wrap } from 'retryyy'

type UserShape = { id: number; name: string }

// Typed custom errors might be provided already by the SDKs you are using,
// but for this example we are creating our own custom error.
class APIError extends Error {
  statusCode: number
  constructor({ statusCode }: { statusCode: number }) {
    this.message = 'API responded with an error'
    this.statusCode = statusCode
  }
}

const _fetchUser = async (id: number) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)

  if (!res.ok) {
    throw new APIError(res)
  }

  return (await res.json()) as UserShape
}

export const fetchUser = wrap(_fetchUser, {
  next: ({ error }) => {
    // Too Many Requests
    if (error instanceof APIError && error.statusCode === 429) {
      // The server is already rate-limiting us, so bail out as re-trying won't
      // make any difference.
      throw error
    }
  },
})

Cancel operations mid-flight

AbortSignal is supported across retryyy's APIs.

import { retryyy } from 'retryyy'

let controller: AbortController | null = null

const handleSubmit = (event: SubmitEvent) => {
  event.preventDefault()

  if (controller) {
    // Do not restart the request if it is already in progress.
    return
  }

  try {
    controller = new AbortController()

    retryyy(
      async () => {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/1`,
          { signal: controller?.signal }, // Pass the signal to the fetch call.
        )
        const user = await res.json()
        console.log(user)
      },
      {
        // Pass an empty object if you don't need to customize the default policy.
      },
      controller.signal, // Pass the signal to the retryyy call.
    )
  } finally {
    controller = null
  }
}

const handleCancel = (event: MouseEvent) => {
  if (controller) {
    controller.abort(new Error('Request cancelled by the user'))
  }
}

document.querySelector('form').addEventListener('submit', handleSubmit)
document.querySelector('.cancel-btn').addEventListener('click', handleCancel)

For functions augmented with wrap() or @Retryyy(), an AbortSignal can be passed as the only argument; a new function will be returned with the signature of the original async function:

import { wrap } from 'retryyy'

// Move the fetching logic outside.
const fetchUser = wrap(async (id: number, signal?: AbortSignal) => {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
    { signal }, // Pass the signal to the fetch call.
  )
  const user = await res.json()
  // We are only fetching now; let the caller decide what to do with the data.
  return user as { id: number; name: string }
})

let controller: AbortController | null = null

const handleSubmit = (event: SubmitEvent) => {
  event.preventDefault()

  if (controller) {
    // Do not restart the request if it is already in progress.
    return
  }

  try {
    controller = new AbortController()

    const user = await fetchUser(controller.signal)(1, controller.signal)
    console.log(user)
  } finally {
    controller = null
  }
}

const handleCancel = (event: MouseEvent) => {
  if (controller) {
    controller.abort(new Error('Request cancelled by the user'))
  }
}

document.querySelector('form').addEventListener('submit', handleSubmit)
document.querySelector('.cancel-btn').addEventListener('click', handleCancel)

It is important to note that in either case the AbortSignal is passed twice: once for retryyy to know when to cancel a scheduled attempt and another for the underlying fetch() call to cancel the inflight HTTP request.

Bandwidth savings

At only 619 bytes (417B gzipped), the core() implementation is a good option for specific use-cases. Its API is the same as that of wrap(), but a policy has to be provided explicitly.

import { core as wrap } from 'retryyy/core'
import type RetryPolicy from 'retryyy/core'

const simpleExamplePolicy: RetryPolicy = ({ attempt, error }) => {
  // Give up after 3 tries.
  if (attempt > 3) {
    throw error
  }

  // Linear backoff, waits 1s, 2s, 3s, 4s, etc.
  return attempt * 1000
}

export const fetchUser = wrap(async (id: number) => {
  // do stuff...
}, simpleExamplePolicy)

In this case all the retry logic has to be implemented from scratch. For high-throughput production systems it is highly advisable to use a smarter backoff + jitter strategy like the PollyJitter policy.

Motivation

In the past, I've used various retry libraries like node-retry, p-retry, and async-retry, but I've always felt at odds with them.

The thing that bothers me the most about existing retry libraries is that they force you to write code in a certain way. Retries are primarily an infrastructure reliability concern and rarely part of your core business logic, so it's best to keep them apart.

Moreover, existing libraries often lack the flexibility to customize retry logic to, for example, applying a different jitter strategy.

Lately, I've been simply hand-rolling my own retry function when needed:

const wait = (ms) =>
  new Promise((resolve, reject) => {
    setTimeout(resolve, ms)
  })

export const retry = (fn, policy) => {
  return async (...args) => {
    const state = {
      attempt: 0,
      elapsed: 0,
      error: null,
      start: Date.now(),
    }

    while (true) {
      try {
        return await fn(...args)
      } catch (error) {
        state.attempt += 1
        state.elapsed = Date.now() - state.start
        state.error = error
        await wait(policy(state))
      }
    }
  }
}

Such small function is pretty much the entirety of retryyy's core implementation.

Contributing

Please refer to CONTRIBUTING.md.

Acknowledgements

Thanks to the inspiration from projects like node-retry, p-retry, async-retry, and cockatiel.

Special thanks to the Polly community and @george-polevoy for their better exponential backoff with jitter.

💙 This package was templated with create-typescript-app.

License

MIT ❤️