skip to content

With package:http's RetryClient, which failures are retried by default, how long does it wait between attempts, and what must you configure yourself?

level: middleimportance: should knowfreq 30%

answer

  1. wraps an inner Client
  2. three retries, four sends
  3. 503 only, errors not retried
  4. 500 ms times 1.5 per retry
  5. no jitter, no method check

basics

~20 s

RetryClient retries 3 times by default, only when the response status is 503; thrown errors such as a dropped connection are not retried. Delays are 500 ms growing 1.5x with no jitter, and it retries any HTTP method.

solid answer

~40 s

`RetryClient` from `package:http/retry.dart` wraps an inner `Client`. By default `retries` is 3, so a request is sent at most four times; `when` retries only responses with status 503; `whenError` returns false, so a `ClientException` from a dropped connection is **not** retried; and `delay` is 500 ms times 1.5 to the power of the retry index — 500, 750, 1125 ms — with no jitter. It does not look at the method, so a POST that gets a 503 is resent. In practice I pass `whenError` for connection errors, widen `when` to 502 and 504, supply a jittered `delay`, and use the retrying client only for idempotent calls. `onRetry` is the hook for logging.

code

dart · 21 lines
dart
import 'dart:developer';
import 'dart:math';

import 'package:http/http.dart' as http;
import 'package:http/retry.dart';

final _rng = Random();

/// Use this client only for idempotent calls (GET, HEAD, PUT, DELETE).
http.Client buildReadClient() => RetryClient(
      http.Client(),
      retries: 4,
      when: (r) => const {502, 503, 504}.contains(r.statusCode),
      whenError: (e, _) => e is http.ClientException,
      delay: (n) {
        final capMs = min(8000, 500 * pow(2, n).toInt());
        return Duration(milliseconds: _rng.nextInt(capMs + 1));
      },
      onRetry: (req, res, n) =>
          log('retry ${n + 1} ${req.method} ${req.url} -> ${res?.statusCode}'),
    );

go deeper

for a junior

Remember that RetryClient wraps another http Client and, left alone, only retries a 503 response a few times.

for a middle

Explain every default — retries, when, whenError, delay — and why connection errors and jitter need explicit configuration.

for a senior

Show how you keep non-idempotent writes out of the retry path, since whenError cannot see the method, and how jitter protects a recovering backend.

for a principal

Weigh a decorator client against a policy layer that also knows about Retry-After, circuit breaking and user-visible latency budgets.

## What RetryClient is `RetryClient` lives in `package:http/retry.dart`. It is a `BaseClient` that **decorates** another `Client`: every `send` goes to the inner client, and if the outcome matches the retry predicates, it waits and sends a copy again. Because it is just a `Client`, it composes with `IOClient`, `cupertino_http`, `cronet_http` or a test client, and the rest of your code keeps calling `get`, `post` and friends. To resend a request it must replay the body, so it pipes the original body through a `StreamSplitter` and builds a fresh `StreamedRequest` for each attempt. The source warns that this copies request data, which can cost a lot of memory for a large `StreamedRequest` such as a photo upload. ## The defaults, read from the source | Parameter | Default | Consequence | |---|---|---| | `retries` | `3` | at most **4** sends in total | | `when` | `statusCode == 503` | 500, 502 and 504 are returned, not retried | | `whenError` | always `false` | a thrown `ClientException` (for example a socket failure) is rethrown immediately | | `delay` | `500 ms * 1.5^retryIndex` | 500 ms, 750 ms, 1125 ms; no randomness | | `onRetry` | `null` | nothing is logged | Two defaults surprise people. First, **network errors are not retried at all** unless you pass `whenError` — the most common mobile failure, a connection dropping mid-request, goes straight to the caller. Second, the client is **method-agnostic**: it will resend a POST that got a 503. A few mechanics round this out: - A retried response that is being discarded has its body stream drained so the connection is not left dangling. - An aborted request (`RequestAbortedException`) is rethrown and never retried. - `onRetry` is called right before each resend, after the delay; its response argument is `null` when the retry was caused by an error. - `RetryClient.withDelays(inner, delays)` takes an explicit list; the number of retries is the list's length. - `close()` closes the inner client. ## Configuring it for a flaky mobile link For a field app on a patchy rural connection, the defaults leave most failures unhandled. A reasonable configuration: 1. Pass `whenError: (e, _) => e is ClientException` so connection failures are retried. `IOClient` wraps socket errors in a `ClientException` that also implements `SocketException`, and aborts are already excluded. 2. Widen `when` to the gateway statuses 502, 503 and 504, and leave 4xx alone — a 400 or 401 will not fix itself. 3. Replace the fixed-ratio `delay` with **capped exponential backoff plus jitter**, so hundreds of devices that lost signal together do not retry in lockstep when the tower comes back. 4. Keep the retry count modest; a user holding a phone will not wait a minute. ## The idempotency gap `whenError` receives only the error and stack trace, not the request, so it cannot tell a GET from a POST. A connection that dropped after the server received a POST leaves you not knowing whether it was applied, and a blind resend can create a duplicate record. The practical fixes are: - keep **two clients**: a `RetryClient` for reads and other idempotent calls, and a plain client for non-idempotent writes; - or make writes idempotent on the server side and only then route them through the retrying client. The `when` predicate can inspect `response.request?.method`, but that only covers the status-code path, not the thrown-error path, so the two-client split is the simpler rule. ## Where RetryClient stops `RetryClient` does not honour a `Retry-After` header, does not know about connectivity, and has no circuit breaker. If those matter, add them in `when`/`delay` logic or a custom `BaseClient`. It also retries within one call only; queueing work to send later when the app is offline is a different mechanism entirely.

  • How would you make RetryClient honour a server's Retry-After header?
    The `delay` callback only receives the retry index, so it cannot see the response. You can record the parsed `Retry-After` value in `when` or `onRetry` and have `delay` read it, but that shares state across concurrent requests. A small custom `BaseClient` wrapper that owns its loop is usually cleaner.
  • Why is the default delay schedule a problem when many devices lose signal together?
    Every client computes the same 500, 750, 1125 ms schedule, so devices that failed at the same moment retry at the same moment and hit the recovering server in waves. Randomising each delay — full jitter between zero and the capped exponential value — spreads the load.

saying these in an interview costs you the question

  • RetryClient retries dropped connections out of the box.
  • RetryClient only retries GET requests, so POSTs are safe.
  • The default backoff already includes random jitter.
  • retries: 3 means the request is sent three times in total.
  • Wrapping a large streamed upload in RetryClient costs no extra memory.