Observable retries via an on_retry callback - #71
Open
snackycracky wants to merge 1 commit into
Open
Conversation
`Retry.retry/2` performs its attempts silently. The core loop is nothing more
than a `Enum.reduce_while/3` over the delay stream that sleeps and re-invokes the
block:
```elixir
unquote(delays_from(stream_builder))
|> Enum.reduce_while(nil, fn delay, _last_result ->
:timer.sleep(delay)
fun.()
end)
```
There is no `Logger` call, no `:telemetry` event, and no user-facing hook
anywhere in the library. (`Retry.Annotation` does `require Logger`, but never
actually logs — it is an unused import.)
This became a problem for a caller that wraps `:erpc.call/5` behind a retry:
```elixir
retry with: constant_backoff(retry[:after]) |> expiry(retry[:limit]),
atoms: &retry?/1 do
do_call(node, module, function_name, function_args, opts)
end
```
With a budget such as `limit: 600_000` (10 minutes) a lost node causes up to ten
minutes of retries that produce **no log line, no metric, and no trace** — the
caller only sees the final `{:error, ...}` after the budget is exhausted. That
silent window is operationally dangerous: an outage looks like a hang.
The alternatives available to callers today are unsatisfying:
- **Log inside the `do` block.** Works, but conflates the "did it fail" decision
with the "should I log" concern, and every call site has to reimplement it.
- **Switch to `retry_while/2`.** Gives per-iteration control, but forces callers
to hand-write the continue/halt logic and the value matching they get for free
from `atoms:`/`rescue_only:`.
Add an optional `on_retry` option to `Retry.retry/2`. It is a one-arity function
invoked with the retriable value (or the rescued exception) immediately before
each **actual** retry.
Semantics were chosen deliberately by driving the callback from the previously
ignored `reduce_while` accumulator (`last_result`) rather than from
`block_runner`:
- It is **not** called for the first attempt.
- It is **not** called for the final, given-up attempt (the stream is exhausted
before the loop runs again). That value/exception is already returned or
re-raised, so notifying about it would be redundant.
- It therefore fires exactly once per real retry — i.e. `attempts - 1` times.
```elixir
retry with: constant_backoff(1_000) |> expiry(10_000),
on_retry: fn
{:error, reason} -> Logger.warning("retrying, last error: #{inspect(reason)}")
exception -> Logger.warning("retrying, last exception: #{inspect(exception)}")
end do
# interact with external service
end
```
The callback runs in the loop, outside the block's own `try/rescue`, so it is
never misinterpreted as a block failure. It is a fire-and-forget observability
hook, so any exception, throw, or exit it produces is **isolated** — a broken
logging call must never abort an in-progress retry. Rather than swallow that
error silently (which would hide a genuinely broken callback), it is **reported
via `Logger`** and then execution continues. `Logger` ships with Elixir, so this
adds no dependency. When `on_retry` is absent the behaviour is byte-for-byte
unchanged.
Scope decisions:
- Only `retry/2` gets the option. `retry_while/2` already hands the caller full
control of each iteration, so they can log inline without a new hook.
- The callback receives just the retriable value/exception (arity 1). It carries
the failure reason, and callers can capture any additional context (target
node, attempt counter, …) from their own closure. We kept the signature
minimal rather than inventing a context struct we would have to version.
- We did **not** add a `:telemetry` dependency. A plain callback keeps the
library dependency-free; a caller who wants telemetry writes
`on_retry: &:telemetry.execute(...)`.
- Retries are observable without changing the value-matching ergonomics of
`retry/2`. The motivating `:erpc` wrapper can log every retriable failure:
```elixir
retry with: constant_backoff(retry[:after]) |> expiry(retry[:limit]),
atoms: &retry?/1,
on_retry: fn {:error, %RPCError{reason: reason}} ->
Logger.warning("RPC to #{inspect(node)} retrying after #{inspect(reason)}")
end do
do_call(node, module, function_name, function_args, opts)
end
```
- Backwards compatible: existing call sites and the default behaviour are
untouched; `on_retry` defaults to `nil`.
- The callback runs synchronously in the retrying process and its runtime counts
against any `expiry/2` budget. Callers must keep it cheap and non-blocking.
- Errors raised, thrown, or exited from the callback do not affect the retry;
they are caught and logged via `Logger.error/1` (with the original stacktrace
via `Exception.format/3`). A broken callback is therefore visible in the logs
instead of failing silently, but callers should still handle their own errors
inside it.
- Because the final give-up value is not delivered through `on_retry`, callers
that also want to log the terminal failure should do so from the return
value / `else` clause, not the callback.
- This does not address the separate hazard that surfaced the issue —
`timeout: :infinity` on the underlying `:erpc.call/5` means a single hung
attempt never returns, so the `expiry/2` budget never fires and no retry (and
thus no `on_retry`) ever happens. Observability of retries is orthogonal to
bounding a single attempt; a finite per-attempt timeout is still required.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Observable retries via an
on_retrycallbackContext
Retry.retry/2performs its attempts silently. The core loop is nothing morethan a
Enum.reduce_while/3over the delay stream that sleeps and re-invokes theblock:
There is no
Loggercall, no:telemetryevent, and no user-facing hookanywhere in the library. (
Retry.Annotationdoesrequire Logger, but neveractually logs — it is an unused import.)
This became a problem for a caller that wraps
:erpc.call/5behind a retry:With a budget such as
limit: 600_000(10 minutes) a lost node causes up to tenminutes of retries that produce no log line, no metric, and no trace — the
caller only sees the final
{:error, ...}after the budget is exhausted. Thatsilent window is operationally dangerous: an outage looks like a hang.
The alternatives available to callers today are unsatisfying:
doblock. Works, but conflates the "did it fail" decisionwith the "should I log" concern, and every call site has to reimplement it.
retry_while/2. Gives per-iteration control, but forces callersto hand-write the continue/halt logic and the value matching they get for free
from
atoms:/rescue_only:.Decision
Add an optional
on_retryoption toRetry.retry/2. It is a one-arity functioninvoked with the retriable value (or the rescued exception) immediately before
each actual retry.
Semantics were chosen deliberately by driving the callback from the previously
ignored
reduce_whileaccumulator (last_result) rather than fromblock_runner:before the loop runs again). That value/exception is already returned or
re-raised, so notifying about it would be redundant.
attempts - 1times.The callback runs in the loop, outside the block's own
try/rescue, so it isnever misinterpreted as a block failure. It is a fire-and-forget observability
hook, so any exception, throw, or exit it produces is isolated — a broken
logging call must never abort an in-progress retry. Rather than swallow that
error silently (which would hide a genuinely broken callback), it is reported
via
Loggerand then execution continues.Loggerships with Elixir, so thisadds no dependency. When
on_retryis absent the behaviour is byte-for-byteunchanged.
Scope decisions:
retry/2gets the option.retry_while/2already hands the caller fullcontrol of each iteration, so they can log inline without a new hook.
the failure reason, and callers can capture any additional context (target
node, attempt counter, …) from their own closure. We kept the signature
minimal rather than inventing a context struct we would have to version.
:telemetrydependency. A plain callback keeps thelibrary dependency-free; a caller who wants telemetry writes
on_retry: &:telemetry.execute(...).Consequences
Retries are observable without changing the value-matching ergonomics of
retry/2. The motivating:erpcwrapper can log every retriable failure:Backwards compatible: existing call sites and the default behaviour are
untouched;
on_retrydefaults tonil.The callback runs synchronously in the retrying process and its runtime counts
against any
expiry/2budget. Callers must keep it cheap and non-blocking.Errors raised, thrown, or exited from the callback do not affect the retry;
they are caught and logged via
Logger.error/1(with the original stacktracevia
Exception.format/3). A broken callback is therefore visible in the logsinstead of failing silently, but callers should still handle their own errors
inside it.
Because the final give-up value is not delivered through
on_retry, callersthat also want to log the terminal failure should do so from the return
value /
elseclause, not the callback.This does not address the separate hazard that surfaced the issue —
timeout: :infinityon the underlying:erpc.call/5means a single hungattempt never returns, so the
expiry/2budget never fires and no retry (andthus no
on_retry) ever happens. Observability of retries is orthogonal tobounding a single attempt; a finite per-attempt timeout is still required.