skip to content

Kotest's assertion library provides both `retry` and `eventually`. They both re-run a block, so what is the actual difference in intent and semantics, and when would you choose `retry`?

level: seniorimportance: should knowfreq 28%

answer

  1. retry acts, eventually observes
  2. retry: maxRetry, timeout, delay, multiplier, exceptionClass
  3. multiplier = geometric backoff
  4. eventually block must be idempotent
  5. retry ≠ a fix for a flaky assertion

basics

~20 s

eventually polls an observation until an assertion holds before a deadline — the block must be side-effect free. retry re-executes a whole flaky operation up to a maximum attempt count, with a delay and an optional backoff multiplier, and only for a named exception class. Retry acts; eventually observes.

solid answer

~60 s

They look similar and mean different things. **`eventually`** is an *assertion*: it repeatedly evaluates an observation of the system until the assertions inside it hold, or the deadline expires. The block must be idempotent, because it runs many times — you put the query in, not the command. **`retry`** is an *operation wrapper*: `retry(maxRetry, timeout, delay, multiplier, exceptionClass) { ... }` re-executes the block up to `maxRetry` times, bounded also by a timeout, waiting `delay` between attempts and multiplying that delay by `multiplier` for backoff. It only retries failures matching `exceptionClass`; anything else propagates. It returns the block's value on success. So `retry` is what you use when the *action itself* is unreliable — a container start, a flaky third-party call during setup, an occasional connection reset — and re-running it is legitimate. `eventually` is what you use when the action already succeeded and you are waiting for its effect to become visible. A useful test: if re-running the block twice would be wrong, you need `retry` around an action, not `eventually` around it.

code

kotlin · 15 lines
kotlin
test("order is projected") {
    val broker = retry(
        maxRetry = 3,
        timeout = 30.seconds,
        delay = 1.seconds,
        multiplier = 2,
        exceptionClass = ConnectException::class,
    ) { brokerClient.connect() }

    broker.publish(OrderPlaced(orderId))

    eventually(5.seconds) {
        readModel.findOrder(orderId).shouldNotBeNull()
    }
}

go deeper

for a junior

Know that retry re-runs an operation a bounded number of times and eventually waits for an assertion to become true. Do not worry about backoff details.

for a middle

Explain the idempotency requirement of the eventually block and name retry's parameters — attempts, timeout, delay, multiplier, exception class — and what the multiplier does.

for a senior

Lead with intent: action vs observation. Discuss why the exception class must be narrow, and push back on retry used as flakiness suppression in CI.

for a principal

Frame it as suite hygiene: retries around actions are acceptable at infrastructure boundaries only; retries around assertions are a debt marker, and you would track how many tests carry them.

## Two superficially similar loops Both functions run a block more than once and both give up eventually. The difference is what the block is allowed to be. ### `eventually` — poll a read `eventually` exists because an effect is not instantly observable. The block is a *read* of the system plus assertions on it. Kotest swallows failures and re-reads until the assertions pass. Because the block is invoked an unbounded number of times, it must be free of side effects: putting `client.post(...)` inside means posting once per poll. ### `retry` — re-run an action `retry` exists because an *action* is unreliable. You want the action to actually happen, and if the first go fails in a known, transient way you want another go. Its signature carries that intent: a maximum retry count, an overall timeout, an initial delay between attempts, a multiplier that grows that delay (exponential-ish backoff), and an exception class that bounds which failures are retryable. On success it returns the block's value; on exhaustion it fails the test with the last failure. ```kotlin val token = retry(maxRetry = 3, timeout = 30.seconds, delay = 1.seconds, multiplier = 2) { authClient.issueToken() } ``` The exception-class parameter is the safety rail. Retrying a `ConnectException` is reasonable; retrying an `IllegalArgumentException` from your own setup code just delays a deterministic failure by three rounds and hides the cause behind a retry log. ## Choosing between them Ask one question: **does the block cause something, or observe something?** - Causes (starts a container, calls an endpoint, writes a row) → `retry`, and only if re-causing is safe. - Observes (queries, reads, asserts) → `eventually`. A second, sharper test: if executing the block twice in a row would corrupt the state you are about to assert on, it does not belong in `eventually`. The typical shape of a healthy integration test combines both without confusing them: ```kotlin val session = retry(3, 30.seconds, 1.seconds) { startSession() } // action, may be flaky session.publish(event) // action, once eventually(5.seconds) { readModel.find(id).shouldNotBeNull() } // observation ``` ## Backoff semantics `retry`'s `delay` and `multiplier` give geometric growth: with a 1-second delay and a multiplier of 2, attempts are spaced roughly 1s, 2s, 4s. That is appropriate for a dependency that may be under load — hammering it every 100ms makes the situation worse. `eventually` reaches the same idea from the other side, through its configurable interval strategy (fixed or growing), because its polls also cost the system under test something. ## Where `retry` is genuinely the right tool - **Test setup against real infrastructure**: acquiring a lease, pulling an image, opening a connection to a broker that has just started. These are actions, not observations. - **Genuinely at-least-once external calls**: an idempotent third-party endpoint that occasionally resets connections. ## Where it is the wrong tool - **Papering over a flaky assertion.** Wrapping a failing test in `retry` so it goes green two times out of three is not a fix; it hides a real race and it multiplies CI time. If the *assertion* is the unstable part, the answer is `eventually` (a genuine waiting problem) or fixing the determinism of the code under test. - **Retrying a non-idempotent action.** Three attempts at "create order" can leave three orders. `retry` has no idea whether re-running is safe — that judgement is yours. - **Broadening the exception class to `Throwable` to make it "work".** That converts every deterministic bug into a slow, thrice-repeated failure. ## The interview answer in one line `eventually` is an assertion about time ("this becomes true by the deadline"); `retry` is an execution policy ("do this again if it fails transiently"). Confusing them shows up as side effects inside polling loops and as retry wrappers used to suppress real flakiness.

  • A colleague wraps a whole failing test in `retry` to stop it going red in CI. What do you say?
    That it converts a signal into noise: the race is still there, it now fails one run in ten instead of one in three, and each failure costs several times the runtime. The right move is to identify what is actually unsynchronised — usually an effect that is not yet visible — and either wait for it explicitly with `eventually` on the narrowest observable, or make the code deterministic with an injected clock or a completion signal.
  • Why does `retry` take an exception class rather than retrying everything?
    Because only some failures are transient. Retrying a connection reset is sensible; retrying an `IllegalArgumentException` from your own setup just repeats a deterministic bug three times and buries the original stack trace under retry noise. Naming the class documents which failure mode you consider transient and makes everything else fail fast.

saying these in an interview costs you the question

  • Using `retry` to suppress a flaky assertion instead of fixing or properly awaiting the race.
  • Putting a state-changing call inside `eventually`, so it fires once per poll.
  • Retrying a non-idempotent action and ending up with duplicate side effects.
  • Setting `retry`'s exception class to `Throwable` so that every deterministic bug is repeated and slowed down.
  • Claiming `retry` and `eventually` are interchangeable because both loop.

context