Dowser.Client.Retry (Dowser.Client v0.3.0)

View Source

Retry policy for transient HTTP failures.

Lives outside Dowser.Client.HTTP deliberately: retry is orchestration around the transport, not a transport concern.

resolve/2 turns opts[:retry] into a full config (stored on %Dowser.Client.Request{}); run/2 drives an arbitrary zero-arity fun through that policy, sleeping between attempts; transient?/1 classifies the transport's raw {:error, reason} payload.

What may be retried

A retry is only safe when the request cannot already have been applied. Three cases, and only the third needs a judgement call:

  • Never reached the server — :econnrefused, :nxdomain, {:failed_connect, _}, :enetunreach, :ehostunreach. Nothing ran. Always retried.
  • The server rejected it — a :retryable_statuses response, 429 and 503 by default. The server said it did not do the work. Always retried.
  • Ambiguous — a timeout, a connection dropped mid-flight, or an :ambiguous_statuses response (502/504, where a proxy answers for a server that may well have applied the request). The request may have taken effect, with no answer to say so. Retried only when the request is idempotent.

That last case is what makes a retried POST duplicate work: a bulk index or an auto-id create that timed out may have been applied in full, and sending it again writes it twice. So :idempotent defaults to the method — GET, HEAD, PUT, DELETE, OPTIONS and TRACE are idempotent, POST and PATCH are not — and a caller who knows better says so per request:

# a search is a POST, but it writes nothing
Dowser.Client.post("/posts/_search", query, retry: [idempotent: true])

Options

opts[:retry] accepts:

  • false — disables retries (max_attempts: 1).
  • a keyword list — merged over the defaults, so a partial override (e.g. retry: [max_attempts: 5]) only changes the given keys:
    • :max_attempts — total attempts, including the first (default 3).
    • :max_elapsed_ms — give up once this much wall-clock time has gone into the request, however many attempts are left (default nil, no budget). A burst of 429s outlasts three attempts; a budget bounds what the caller waits for.
    • :base_delay_ms / :max_delay_ms — exponential full-jitter backoff bounds (default 200 / 2_000).
    • :retryable_statuses — statuses the server rejected the request with (default [429, 503]).
    • :ambiguous_statuses — statuses that may or may not mean the request was applied (default [502, 504]), retried only when the request is idempotent.
    • :idempotent — whether re-sending this request is harmless (default: derived from the HTTP method).
    • :respect_retry_after — obey a Retry-After response header, given in seconds or as an HTTP date, in place of the computed backoff (default true).
    • :max_retry_after_ms — cap on a Retry-After the server asks for (default 60_000). Past the cap the request gives up rather than sleeping as asked.

Summary

Functions

Whether a failure leaves it unknown whether the request was applied — false for one that demonstrably never reached the server.

Resolves opts[:retry] into a full retry config, with :idempotent defaulted from method unless the caller set it.

Runs fun up to config[:max_attempts] times, retrying as long as the result may safely be retried (see the module documentation) and both the attempts and the time budget last, sleeping in between.

Whether an adapter's raw {:error, reason} payload looks transient — worth retrying at all, idempotence aside.

Types

config()

@type config() :: keyword()

Functions

ambiguous?(reason)

@spec ambiguous?(term()) :: boolean()

Whether a failure leaves it unknown whether the request was applied — false for one that demonstrably never reached the server.

A non-idempotent request is not retried when this is true: that is what keeps a timed-out write from being applied twice.

resolve(opts, method \\ nil)

@spec resolve(keyword(), atom() | nil) ::
  {:ok, config()} | {:error, Dowser.Client.Error.t()}

Resolves opts[:retry] into a full retry config, with :idempotent defaulted from method unless the caller set it.

Returns {:ok, config} or {:error, %Dowser.Client.Error{}} when opts[:retry] is neither false nor a keyword list.

run(config, fun)

@spec run(config(), (-> term())) :: {term(), pos_integer()}

Runs fun up to config[:max_attempts] times, retrying as long as the result may safely be retried (see the module documentation) and both the attempts and the time budget last, sleeping in between.

Returns {result, attempts} — fun's final return value and the number of times it was actually called.

transient?(reason)

@spec transient?(term()) :: boolean()

Whether an adapter's raw {:error, reason} payload looks transient — worth retrying at all, idempotence aside.