retryyy
v1.0.0
Published
A better way to retry async operations in TypeScript/JavaScript.
Downloads
11
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:
breaker
: Bails out from the operation after 5 attempts.backoff
: Exponential backoff starting at 1 second.jitter
: Adds some random time to thedelay
returned by thebackoff
(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 ❤️