| Title: | Python-Style Decorators for R |
|---|---|
| Description: | Popular Python decorators ported to R with the 'adorn' framework: retrying, caching, context managers and argument validation. |
| Authors: | Dyfan Jones [aut, cre] |
| Maintainer: | Dyfan Jones <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.1.0 |
| Built: | 2026-11-01 07:51:51 UTC |
| Source: | https://github.com/DyfanJones/garnish |
retry() is built from five kinds of strategy. Each has an abstract base
class and a generic function, both exported, so you can add your own the same
way the built-in ones are written with S7:
after_sleep_base() after_sleep_fn(class, state, ...) before_sleep_base() before_sleep_fn(class, state, ...) retry_base() retry_fn(class, outcome, ...) wait_base() wait_fn(class, attempt, prev_wait = NULL, ...) stop_base() stop_fn(class, state, ...)after_sleep_base() after_sleep_fn(class, state, ...) before_sleep_base() before_sleep_fn(class, state, ...) retry_base() retry_fn(class, outcome, ...) wait_base() wait_fn(class, attempt, prev_wait = NULL, ...) stop_base() stop_fn(class, state, ...)
class |
A strategy object. Methods are dispatched on this argument. |
state, outcome, attempt, prev_wait
|
Described above. |
... |
Passed to methods. |
retry() argument |
Base class | Generic | The method returns |
stop |
stop_base |
stop_fn(class, state) |
TRUE to give up |
wait |
wait_base |
wait_fn(class, attempt, prev_wait) |
seconds to sleep |
retry |
retry_base |
retry_fn(class, outcome) |
TRUE to try again |
before_sleep |
before_sleep_base |
before_sleep_fn(class, state) |
nothing |
after_sleep |
after_sleep_base |
after_sleep_fn(class, state) |
nothing |
To add a strategy, make a class that inherits from the base class, then
register a method for the generic with S7::method<-(). Create the object
with new_object() on an instance of the base class, as the built-in
strategies do. Load S7 with library(S7) to get new_class(),
new_object() and method<-.
Strategies of the same kind compose with the built-in ones. A new wait works
in wait_chain() and with +, and a new stop works in stop_any() and
stop_all().
If you register methods from another package, call S7::methods_register()
in its .onLoad().
stop_fn() and retry_fn() return a single TRUE or FALSE,
wait_fn() returns a number of seconds, and the hook generics return
nothing useful.
state, for stop_fn(), before_sleep_fn() and after_sleep_fn(), is a
list describing the attempt that just failed: attempt (1-based), wait
(seconds retry() is about to sleep), elapsed (seconds since the first
attempt), error (the condition caught, or NULL if retry_if_result()
rejected a returned value) and value (the value returned, or NULL if
the call errored).
attempt and prev_wait, for wait_fn(), are the 1-based attempt number
and the previous sleep in seconds (NULL on the first retry).
outcome, for retry_fn(), is list(value, error) for the attempt that
just finished. error is NULL when the call returned normally, and
value is NULL when it errored.
library(S7) # Wait `step` seconds longer after each failure wait_linear <- new_class( "wait_linear", parent = wait_base, properties = list(step = new_property(class_numeric, default = 1)) ) method(wait_fn, wait_linear) <- function(class, attempt, prev_wait = NULL, ...) { class@step * attempt } f <- retry( stop = stop_after_attempt(3), wait = wait_linear(0.01) + wait_fixed(0.01) ) %@% function() stop("flaky") try(f(), silent = TRUE)library(S7) # Wait `step` seconds longer after each failure wait_linear <- new_class( "wait_linear", parent = wait_base, properties = list(step = new_property(class_numeric, default = 1)) ) method(wait_fn, wait_linear) <- function(class, attempt, prev_wait = NULL, ...) { class@step * attempt } f <- retry( stop = stop_after_attempt(3), wait = wait_linear(0.01) + wait_fixed(0.01) ) %@% function() stop("flaky") try(f(), silent = TRUE)
retry() sleepsafter_sleep_none() does nothing.
after_sleep_log() passes a message describing the attempt to logger.
after_sleep_none() after_sleep_log(logger = message)after_sleep_none() after_sleep_log(logger = message)
logger |
Function taking a single message string. Defaults to |
A hook to pass to retry().
after_sleep_log(function(msg) cat(msg, "\n"))after_sleep_log(function(msg) cat(msg, "\n"))
Decorator form of a contextmanager(): the decorated function runs inside the
context, and cleanup runs when it returns or errors. As in Python's
ContextDecorator, the context's value is not passed to the function.
as_decorator(ctx, ...)as_decorator(ctx, ...)
ctx |
A context manager. |
... |
Arguments for |
A decorator.
local_tempdir := contextmanager %@% function() { path <- tempfile() dir.create(path) teardown(unlink(path, recursive = TRUE)) path } g := as_decorator(local_tempdir) %@% function() "ran inside context" g()local_tempdir := contextmanager %@% function() { path <- tempfile() dir.create(path) teardown(unlink(path, recursive = TRUE)) path } g := as_decorator(local_tempdir) %@% function() "ran inside context" g()
retry() sleepsbefore_sleep_none() does nothing.
before_sleep_log() passes a message describing the attempt to logger.
before_sleep_none() before_sleep_log(logger = message)before_sleep_none() before_sleep_log(logger = message)
logger |
Function taking a single message string. Defaults to |
A hook to pass to retry().
before_sleep_log(function(msg) cat(msg, "\n"))before_sleep_log(function(msg) cat(msg, "\n"))
Decorator that stops calling a function after it fails repeatedly, so callers fail fast instead of waiting on something that is down. It has three states:
circuit_breaker(f, failures = 5, reset_after = 30)circuit_breaker(f, failures = 5, reset_after = 30)
f |
The function to decorate. Usually supplied by |
failures |
Number of consecutive errors that open the circuit. A whole number of at least 1. Defaults to 5. |
reset_after |
Seconds the circuit stays open before a trial call. A number greater than 0. Defaults to 30. |
closed: calls go through. Consecutive errors are counted.
open: after failures consecutive errors, calls fail immediately with
a garnish_circuit_open error, without running the function, for
reset_after seconds.
half-open: once the cooldown has passed, the next call runs as a trial. If it succeeds the circuit closes. If it fails the circuit opens again for another cooldown.
Only errors count as failures. A call that returns normally, whatever its value, resets the count. Each decorated function keeps its own state.
Stack it outside retry() so an open circuit rejects a call before any
retries happen:
fetch := circuit_breaker(failures = 3, reset_after = 30) %@%
retry(stop = stop_after_attempt(3)) %@%
function(url) { ... }
A decorator.
n <- 0 flaky <- function() { n <<- n + 1 stop("service down") } f := circuit_breaker(failures = 2, reset_after = 60) %@% flaky try(f()) try(f()) try(f()) # rejected without calling flaky() n # still 2n <- 0 flaky <- function() { n <<- n + 1 stop("service down") } f := circuit_breaker(failures = 2, reset_after = 60) %@% flaky try(f()) try(f()) try(f()) # rejected without calling flaky() n # still 2
Port of Python's contextlib.contextmanager. R has no yield, so instead of
suspending the function the body calls teardown() right after acquiring each
resource and returns the resource. From that one definition you get:
contextmanager(f) teardown(expr)contextmanager(f) teardown(expr)
expr |
Code that releases the resource. Evaluated in the setup function's environment when the context ends, so it can use the setup's local variables. |
f |
The function to decorate. Usually supplied by |
a local_ form: the function itself. Cleanup runs when the calling
function exits, like withr::local_*().
a with_ form: with_context() runs a block, then cleans up.
a decorator: as_decorator() runs a function inside the context.
The decorated function takes ..., passed on to the setup function, and a
.local_envir argument naming the frame whose exit triggers cleanup. It
defaults to the caller, or, when called from another context manager's setup,
to that context's .local_envir, so composed contexts last as long as the
outer one.
teardown(expr) registers expr with withr::defer() on that frame. Cleanups
run last in, first out, and the ones already registered still run if the rest
of the setup fails. teardown() finds the frame by searching the call stack
for the nearest running context manager and reading its .local_envir.
contextmanager is a decorator. teardown() is called for its side
effect.
with_context(), as_decorator()
local_tempdir := contextmanager %@% function(pattern = "tmp") { path <- tempfile(pattern) dir.create(path) teardown(unlink(path, recursive = TRUE)) path } f <- function() { d <- local_tempdir() writeLines("hi", file.path(d, "x.txt")) d } d <- f() dir.exists(d) # cleaned up when f() exitedlocal_tempdir := contextmanager %@% function(pattern = "tmp") { path <- tempfile(pattern) dir.create(path) teardown(unlink(path, recursive = TRUE)) path } f <- function() { d <- local_tempdir() writeLines("hi", file.path(d, "x.txt")) d } d <- f() dir.exists(d) # cleaned up when f() exited
Decorators that remember a function's results, keyed on the matched arguments (including defaults).
lru_cache(f, maxsize = 1024 * 1024^2) cache(f) disk_cache(f, dir, maxsize = 1024 * 1024^2, format = c("rds", "qs2"))lru_cache(f, maxsize = 1024 * 1024^2) cache(f) disk_cache(f, dir, maxsize = 1024 * 1024^2, format = c("rds", "qs2"))
f |
The function to decorate. Usually supplied by |
maxsize |
Maximum cache size in bytes. Defaults to 1 GiB. |
format |
File format for |
dir |
Directory for |
lru_cache() evicts the least recently used entries when the cache is full.
Port of Python's functools.lru_cache.
cache() never evicts. Port of Python's functools.cache.
disk_cache() stores results as files in dir, so they survive the R
session and can be shared between sessions. Like joblib.Memory.cache.
Entries are keyed on the function's formals and body as well as its arguments,
so editing the function invalidates them. Changes to anything it calls do not.
disk_cache() saves results with saveRDS(), or with the qs2 package when
format = "qs2", which is usually faster and smaller for large objects.
Neither round-trips environments or external pointers. Files from the two
formats are not shared, so changing format for an existing dir starts with
an empty cache.
Call lru_cache(maxsize) to build a decorator, then apply it with %@% or by
calling it on a function. cache takes no parameters, so it is applied
directly.
A decorator.
slow_square <- lru_cache() %@% function(x) { Sys.sleep(0.1) x^2 } slow_square(4) # slow slow_square(4) # cached slow_cube <- cache %@% function(x) { Sys.sleep(0.1) x^3 } slow_cube(2) # slow slow_cube(2) # cached dir <- tempfile("cache") slow_cube <- disk_cache(dir) %@% function(x) { Sys.sleep(0.1) x^3 } slow_cube(2) # slow, and saved to `dir` slow_cube(2) # cachedslow_square <- lru_cache() %@% function(x) { Sys.sleep(0.1) x^2 } slow_square(4) # slow slow_square(4) # cached slow_cube <- cache %@% function(x) { Sys.sleep(0.1) x^3 } slow_cube(2) # slow slow_cube(2) # cached dir <- tempfile("cache") slow_cube <- disk_cache(dir) %@% function(x) { Sys.sleep(0.1) x^3 } slow_cube(2) # slow, and saved to `dir` slow_cube(2) # cached
Decorator that allows at most calls calls in any window of period seconds.
A call that would exceed the limit sleeps until the oldest call in the window
has aged out. Calls are counted when they start, so a call that errors still
uses up its slot, as it does for an API.
rate_limit(f, calls, period = 1)rate_limit(f, calls, period = 1)
f |
The function to decorate. Usually supplied by |
calls |
Maximum number of calls in one window. A whole number of at least 1. |
period |
Length of the window in seconds. Defaults to 1. |
Call rate_limit(calls, period) to build a decorator, then apply it with %@%
or by calling it on a function. Each decorated function keeps its own record of
calls, so two functions decorated separately have separate limits.
Stack it inside retry() so that every attempt is throttled, including
retries. Stacking it outside would only throttle whole calls, and retries
could still exceed the limit.
A decorator.
f <- rate_limit(calls = 3, period = 1) %@% function(x) x vapply(1:3, f, numeric(1)) # immediate system.time(f(4)) # waits for the window to clear # Throttle every attempt, retries included fetch <- retry(stop = stop_after_attempt(3)) %@% rate_limit(calls = 5, period = 1) %@% function(url) urlf <- rate_limit(calls = 3, period = 1) %@% function(x) x vapply(1:3, f, numeric(1)) # immediate system.time(f(4)) # waits for the window to clear # Throttle every attempt, retries included fetch <- retry(stop = stop_after_attempt(3)) %@% rate_limit(calls = 5, period = 1) %@% function(url) url
Decorator that calls a function again until it succeeds or a stop condition condition is met, sleeping between attempts. Port of Python's tenacity.
retry( f, stop = stop_never(), wait = wait_none(), retry = retry_all(), before_sleep = before_sleep_none(), after_sleep = after_sleep_none(), reraise = FALSE, retry_error_callback = NULL )retry( f, stop = stop_never(), wait = wait_none(), retry = retry_all(), before_sleep = before_sleep_none(), after_sleep = after_sleep_none(), reraise = FALSE, retry_error_callback = NULL )
f |
The function to decorate. Usually supplied by |
stop |
A stop condition, e.g. |
wait |
A wait strategy. Defaults to no waiting. |
retry |
A retry condition. Defaults to retrying every error. |
before_sleep, after_sleep
|
Hooks run before and after each sleep, see
|
reraise |
If |
retry_error_callback |
Optional function called with the final state when
|
Call retry(stop, wait, retry, before_sleep, after_sleep, reraise, retry_error_callback) to build a decorator, then apply it with %@% or by
calling it on a function.
A decorator. The decorated function returns the first acceptable result,
or signals a retry_error condition (with $attempt, $error and $value)
once stop fires.
n <- 0 flaky <- function() { n <<- n + 1 if (n < 3) stop("not yet") n } retry(stop = stop_after_attempt(5))(flaky)() # Back off exponentially between attempts f <- retry(stop = stop_after_attempt(3), wait = wait_exp(min = 0.1)) %@% function() 1 f()n <- 0 flaky <- function() { n <<- n + 1 if (n < 3) stop("not yet") n } retry(stop = stop_after_attempt(5))(flaky)() # Back off exponentially between attempts f <- retry(stop = stop_after_attempt(3), wait = wait_exp(min = 0.1)) %@% function() 1 f()
Decide whether retry() tries again after an attempt.
retry_all() retry_never() retry_if_error(classes = "error") retry_if_error_message(message = NULL, match = NULL) retry_if_result(predicate = function() NULL)retry_all() retry_never() retry_if_error(classes = "error") retry_if_error_message(message = NULL, match = NULL) retry_if_result(predicate = function() NULL)
classes |
Condition classes to retry on. |
message |
Error message to match exactly. |
match |
Regular expression to match against the error message. |
predicate |
Function taking the returned value and returning |
retry_all() retries on any error.
retry_never() never retries, so the first error is re-thrown.
retry_if_error() retries on errors inheriting from any of classes.
retry_if_error_message() retries when the error message equals message, or
matches the regular expression match. Supply exactly one.
retry_if_result() retries when the call succeeds but predicate(value) is TRUE.
A retry condition to pass to retry().
retry_if_error("http_error") retry_if_error_message(match = "timed? ?out") retry_if_result(is.null)retry_if_error("http_error") retry_if_error_message(match = "timed? ?out") retry_if_result(is.null)
Decide when retry() gives up. A condition is checked after each failed attempt.
stop_never() stop_after_attempt(max_attempt_number = 1) stop_after_delay(max_delay = 1) stop_before_delay(max_delay = 1) stop_any(...) stop_all(...)stop_never() stop_after_attempt(max_attempt_number = 1) stop_after_delay(max_delay = 1) stop_before_delay(max_delay = 1) stop_any(...) stop_all(...)
max_attempt_number |
Number of attempts to allow. |
max_delay |
Seconds since the first attempt. |
... |
Stop conditions to combine. |
stop_never() never stops.
stop_after_attempt() stops after a number of attempts.
stop_after_delay() stops once that many seconds have passed since the first attempt.
stop_before_delay() stops if sleeping again would reach that many seconds.
stop_any() stops when any of the given conditions is met.
stop_all() stops when all of the given conditions are met.
A stop condition to pass to retry().
stop_after_attempt(3) stop_any(stop_after_attempt(5), stop_after_delay(10))stop_after_attempt(3) stop_any(stop_after_attempt(5), stop_after_delay(10))
Decorators that check a function's inputs or output against validators you
supply. A validator is any function (or rlang::as_function() formula) that
takes the value, so base predicates, lambdas and the check_*() functions of
packages like checkmate all work, with no dependency on any of them.
validate_args(...) validate_return(validator)validate_args(...) validate_return(validator)
... |
For |
validator |
For |
A validator passes if it returns exactly TRUE. It fails if it returns
anything else:
a string, which becomes the failure message (the check_*() convention),
FALSE or any other value, which gives a generic message.
If a validator signals an error, that error propagates unchanged, so
assert_*() style functions work too.
validate_args() checks the effective value of each named argument, so
defaults are validated too. Checking forces the argument, so a validated
argument is evaluated at call time rather than lazily. All failures are reported in one error of
class garnish_validation_error. Validator names are checked against the
function's formals when it is decorated, so a typo fails early.
A decorator.
f <- validate_args(x = is.numeric, n = ~ .x > 0) %@% function(x, n = 1) x * n f(2, 3) try(f("a", -1)) # check_*() style validators return a message on failure check_string <- function(x) if (is.character(x) && length(x) == 1) TRUE else "must be a string" g <- validate_args(name = check_string) %@% function(name) name try(g(1)) h <- validate_return(is.character) %@% function() 1 try(h())f <- validate_args(x = is.numeric, n = ~ .x > 0) %@% function(x, n = 1) x * n f(2, 3) try(f("a", -1)) # check_*() style validators return a message on failure check_string <- function(x) if (is.character(x) && length(x) == 1) TRUE else "must be a string" g <- validate_args(name = check_string) %@% function(name) name try(g(1)) h <- validate_return(is.character) %@% function() 1 try(h())
Decide how many seconds retry() sleeps after a failed attempt.
wait_fixed(wait = integer(0)) wait_none() wait_exp(multiplier = 1, min = 0, max = 4611686018427387904, exp_base = 2) wait_exp_jitter(base = 0.1, max_delay = 20, jitter = "full") wait_rand(min = 0, max = 1) wait_chain(...) wait_combine(...)wait_fixed(wait = integer(0)) wait_none() wait_exp(multiplier = 1, min = 0, max = 4611686018427387904, exp_base = 2) wait_exp_jitter(base = 0.1, max_delay = 20, jitter = "full") wait_rand(min = 0, max = 1) wait_chain(...) wait_combine(...)
wait |
Seconds to wait. |
multiplier, exp_base
|
Scale and base of the exponential. |
min, max
|
Lower and upper bound in seconds. |
base |
Seconds to wait on the first attempt. |
max_delay |
Longest wait in seconds. |
jitter |
Jitter type, one of |
... |
Wait strategies to chain or combine. |
wait_none() does not wait.
wait_fixed() waits a fixed time.
wait_rand() waits a random time between min and max.
wait_exp() waits multiplier * exp_base^(attempt - 1), kept between min and max.
wait_exp_jitter() waits base * 2^(attempt - 1), capped at max_delay, then
applies jitter: "none", "full" (uniform from 0), "equal" (half fixed, half
random) or "decorrelated" (random between base and 3 times the previous wait).
wait_chain() uses the n-th strategy for the n-th attempt, then repeats the last.
wait_combine() adds strategies together. a + b is the same as wait_combine(a, b).
A wait strategy to pass to retry().
wait_fixed(2) wait_exp(multiplier = 0.5, max = 30) wait_chain(wait_fixed(1), wait_fixed(5)) wait_fixed(1) + wait_rand(0, 2)wait_fixed(2) wait_exp(multiplier = 0.5, max = 30) wait_chain(wait_fixed(1), wait_fixed(5)) wait_fixed(1) + wait_rand(0, 2)
The scoped form of a contextmanager(). Evaluates ctx with cleanup tied to
this call, passes the value it returns to code, and cleans up when code
finishes, even if it errors.
with_context(ctx, code)with_context(ctx, code)
ctx |
A call to a context manager, e.g. |
code |
A function taking the context's value. |
The value of code.
local_tempdir := contextmanager %@% function() { path <- tempfile() dir.create(path) teardown(unlink(path, recursive = TRUE)) path } with_context(local_tempdir(), function(d) dir.exists(d))local_tempdir := contextmanager %@% function() { path <- tempfile() dir.create(path) teardown(unlink(path, recursive = TRUE)) path } with_context(local_tempdir(), function(d) dir.exists(d))