Packages

retry

Call a function again when it fails, with a growing wait between attempts, and get the outcome back as a result.

import "std:retry"

Networks drop, files are briefly locked and services restart. std:retry calls a function again when it fails, waiting a little longer each time, so a short fault doesn't become your program's failure. The outcome comes back as a result: the first success, or an error once the attempts run out.

The simplest retry#

retry.call(fn) calls fn up to three times. fn receives the attempt number, starting at 1. An attempt fails when fn throws or returns a failed result:

main.osl
import "std:retry"

auto outcome = retry.call(def(int attempt) string (
  log "attempt " ++ attempt
  if attempt < 3 (
    throw error("NetError", "server busy")
  )
  return "got the data"
))

log outcome.unwrap()
Click the code to edit it

The value comes back as a result because every attempt can fail. Handle it as you would any other, with unwrap, unwrapOr, switch or catch.

When every attempt fails#

If the attempts run out, you get an error. It has the kind of the last failure, and a message that says how many attempts were made and what the last one said:

main.osl
import "std:retry"

auto outcome = retry.call(def(int attempt) int (
  throw error("NetError", "down on attempt " ++ attempt)
))

switch outcome (
  case ok(value)
    log "worked: " ++ value
  case err(e)
    log e.kind
    log e.message
)
Click the code to edit it

Keeping the kind means code that handles NetError elsewhere keeps working when the call goes through retry.

Failing without throwing#

A function that returns a result instead of throwing counts too. A failed result is a failed attempt, and a successful one is unwrapped for you:

main.osl
import "std:retry"

def flaky(int attempt) result<int, error> (
  if attempt < 2 (
    return result.err(error("StateError", "not ready"))
  )
  return result.ok(attempt * 10)
)

log retry.call(flaky).unwrap()
Click the code to edit it

Options#

The second argument is an object of options:

  • attempts: how many calls to make in all (default 3).
  • delay: milliseconds to wait after the first failure (default 0).
  • factor: what the wait is multiplied by after each failure (default 2).
  • maxDelay: the longest wait, in milliseconds (default 30000).
  • jitter: from 0 to 1, how much to randomise each wait, up to that fraction either way (default 0).
main.osl
import "std:retry"

int calls = 0

auto outcome = retry.call(def() string (
  calls++
  if calls < 5 (
    throw error("TimeoutError", "no answer")
  )
  return "answered"
), {attempts: 6, delay: 5})

log outcome.unwrap()
log calls
Click the code to edit it

Waits of 5, 10, 20 and 40 milliseconds happened between the five calls. An option out of range, such as attempts: 0, throws a ValueError.

Working out the waits#

retry.delayFor(attempt, options) says how many milliseconds call waits after a given failed attempt, with the same options. It's useful to check a policy before you use it:

main.osl
import "std:retry"

for attempt in 1 to 6 (
  log attempt retry.delayFor(attempt, {delay: 100, maxDelay: 1000})
)
Click the code to edit it

Each wait doubles until it reaches maxDelay. Set factor: 1 for a fixed wait.

Jitter#

When many clients fail at the same moment and all retry on the same schedule, they hit the service together again. jitter spreads the waits out. With jitter: 0.5 each wait lands within half of its normal length either side, so a 100 ms wait is between 50 and 150:

main.osl
import "std:retry"

boolean inRange = true
loop 20 (
  number ms = retry.delayFor(1, {delay: 100, jitter: 0.5})
  if ms < 50 or ms > 150 (
    inRange = false
  )
)
log inRange
Click the code to edit it

Retrying on a thread#

retry.call blocks the calling thread while it waits between attempts. To keep the rest of your program moving, put it inside an async function and wait for the thread when you need the answer:

main.osl
import "std:retry"

async fetchWithRetry(string name) result<string, error> (
  return retry.call(def(int attempt) string (
    if attempt < 2 (
      throw error("NetError", name ++ " was busy")
    )
    return name ++ " loaded"
  ), {attempts: 3, delay: 10})
)

auto a = fetchWithRetry("users")
auto b = fetchWithRetry("orders")
log "both started"
log a.wait().unwrap()
log b.wait().unwrap()
Click the code to edit it

To limit the total time, wait with a timeout, as in a.wait({timeout: 2000}). The thread keeps retrying after a timeout, so it bounds how long you wait, not how long it works.

Only retry what can pass#

retry.call retries every failure. A wrong password will be wrong on the third attempt too, so a permanent problem should not look like a failure to retry. Return it as an ordinary value, and keep throwing only for the faults that can clear up:

main.osl
import "std:retry"

def login(string password, int attempt) string (
  if attempt == 1 (
    throw error("NetError", "connection reset")
  )
  if password != "secret" (
    return "denied"
  )
  return "welcome"
)

for password in ["secret", "guess"] (
  auto outcome = retry.call(def(int attempt) string -> login(password, attempt), {delay: 1})
  log password outcome.unwrap()
)
Click the code to edit it

Both calls failed once with the network error and were retried. The wrong password came back as "denied" on the second attempt, which is a success as far as retry is concerned, so it stopped there.

Quick check

fn throws on every call and you pass no options. How many times does retry.call(fn) call it?

Your turn

One more attempt

This call needs four attempts but only gets the default three, so it gives up. Add an option so it prints the success.

main.osl
Ctrl Enter runs
Expected output
ok on 4

API reference#

4 members, generated from the std:retry source of OSL 9.3.0.

Functions

#retry.backoff(any attempt, any ...options) number

Deprecated. Deprecated: use retry.delayFor(attempt, options), which works in milliseconds, instead.

backoff returns the delay in seconds after a failed attempt, with options in seconds (delay, factor, max_delay, jitter) clamped into range.

#retry.call(any fn, object? options) result<any, error>

call calls fn until it succeeds or the attempts run out, and returns the first successful value. fn receives the attempt number, starting at 1; an attempt fails when fn throws or returns a failed result, and a successful result is unwrapped. Between attempts it waits delay milliseconds, multiplied by factor after each failure and capped at maxDelay. When every attempt fails, the error has the last failure's kind and a message naming the attempt count and that failure. Options: attempts (default 3), delay (ms, default 0), factor (default 2), maxDelay (ms, default 30000), jitter (0 to 1, default 0: randomises each delay by up to that fraction either way). Throws TypeError when fn isn't a function and ValueError for an option out of range.

#retry.delayFor(int attempt, object? options) number

delayFor returns how many milliseconds retry.call waits after the given failed attempt (1 or more) with the same options; a jitter option randomises it the same way. Throws ValueError when attempt is below 1 or an option is out of range.

#retry.run(any callback, any ...options) object

Deprecated. Deprecated: use retry.call(fn, options), which returns a result and takes milliseconds, instead.

run calls callback until it succeeds and returns an object with success, value, error, attempts and elapsed (seconds) keys; options are in seconds.