skip to content

In Ruby, how does retry behave inside a rescue clause, and how do you retry a payment-gateway call a bounded number of times?

level: middleimportance: must knowfreq 58%

answer

  1. back to the top of the body
  2. legal only inside rescue
  3. counter lives outside the begin
  4. ensure runs once, at the end
  5. rescue only transient classes

basics

~20 s

retry, legal only inside a rescue clause, restarts the begin body (or method or do...end body) from its first line. Keep an attempt counter outside that body, retry while under a limit, then re-raise; ensure runs once, after the last attempt.

solid answer

~40 s

`retry` jumps from a `rescue` clause back to the first statement of the protected body: the `begin` block, or the method or `do...end` block when the rescue is attached to it. It does not resume at the failing line, and it has no built-in limit, so the attempt counter must be initialised **before** the `begin`; a counter set inside the body is reset on every retry and the loop never ends. For a payment gateway: rescue only transient classes such as `Net::ReadTimeout`, `Net::OpenTimeout` or `Errno::ECONNRESET`, `retry` while `attempts < 3` (ideally after a short `sleep`), and otherwise re-raise. `ensure` does not run between attempts; it runs once when the block finally finishes, which is where the connection is closed. `retry` anywhere but a rescue clause is a `SyntaxError`.

code

ruby · 18 lines
ruby
require "net/http"

def charge(order_id, cents)
  http = Net::HTTP.new("gateway.example", 443)
  http.use_ssl = true
  attempts = 0                       # outside the body: survives retry
  begin
    attempts += 1
    http.start unless http.started?
    http.post("/charges", "order=#{order_id}&cents=#{cents}").code == "201"
  rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET
    raise if attempts >= 3
    sleep(0.2 * attempts)
    retry
  ensure
    http.finish if http.started?     # runs once, after the last attempt
  end
end

go deeper

for a junior

Remember that retry reruns the begin body from the top, only works inside rescue, and needs a counter to stop.

for a middle

Explain where the counter must live, why ensure runs only once, and why only transient exception classes should trigger a retry.

for a senior

Discuss whether the operation is safe to repeat, backoff between attempts, idempotency keys for payments, and cleanup that cannot itself raise from ensure.

for a principal

Decide where retry policy lives: inline retry for one call site, or a shared wrapper with consistent limits, backoff and logging across services.

## What retry does `retry` is a keyword that can appear only inside a **`rescue` clause**. It transfers control back to the **start of the protected body**: - for `begin ... rescue ... end`, the first statement after `begin`; - for a rescue attached to a method (`def ... rescue ... end`), the first statement of the method body; - for a rescue attached to a `do ... end` block, the first statement of the block body. Everything in the body runs again, including statements before the one that failed. Ruby adds no limit or delay; the syntax documentation warns you to be careful not to create an infinite loop. Using `retry` outside a rescue clause, or inside an `else` or `ensure` clause, is rejected as a `SyntaxError` when the file is parsed. ## Bounding the attempts A safe retry has five parts: 1. **Initialise the counter before `begin`.** A local assigned before the handler survives each `retry`; one assigned inside the body is reset every time. 2. **Increment it inside the body**, so each attempt counts. 3. **Rescue only transient failures.** Timeouts and connection resets are worth another try; `ArgumentError` or `NoMethodError` will fail identically every time. 4. **Check the limit in the rescue clause.** `retry if attempts < 3`. 5. **Re-raise when the limit is hit**, so the caller sees the real error instead of a silent `nil`. A short `sleep` that grows with the attempt number, such as `sleep(0.2 * attempts)`, gives a struggling service time to recover. ## How ensure interacts with retry `retry` does not leave the `begin` block, so **`ensure` does not run between attempts**. It runs once, after the final attempt succeeds or the final exception is re-raised. That makes `ensure` the right place to close the gateway connection: one close, whatever happened. | Event | rescue runs | ensure runs | |---|---|---| | attempt 1 times out | yes, calls `retry` | no | | attempt 2 times out | yes, calls `retry` | no | | attempt 3 succeeds | no | yes, once | | attempt 3 times out | yes, re-raises | yes, once, then the error leaves | ## Retrying a payment call safely In the snippet, the connection is opened inside the body only if it is not already started, so a retry after a dropped connection reconnects, while the `ensure` closes it once at the end. Two details deserve attention in an interview: - A read timeout on a charge does not mean the charge failed. The gateway may have processed it and the reply was lost. Retrying a non-idempotent request can double-charge, so real integrations send an **idempotency key** the gateway uses to recognise the repeat. - Net::HTTP's `finish` raises `IOError` when the session was never started, which is why the `ensure` checks `started?` first. An exception raised from `ensure` would replace the error you were trying to report. ## Common bugs - `attempts = 0` written inside `begin`: every retry resets it, so the loop never ends. - A bare `rescue` with `retry`: a typo that raises `NoMethodError` is retried until the limit, delaying the real error. - No backoff: three attempts in a few milliseconds against an overloaded service rarely help. - Expecting `retry` to resume at the failing line: side effects earlier in the body run again, so they must be safe to repeat. ## retry versus a loop Some teams prefer an explicit loop, `3.times do ... end` with a `break` on success, because the attempt count is visible in the loop header. Both work; `retry` keeps the happy path unindented and puts the retry decision next to the error class that justifies it. Whichever you pick: - keep the limit and the delay in one place, ideally constants; - log each failed attempt with its number, so a slow gateway shows up in monitoring before it becomes an outage; - re-raise the original exception after the last attempt instead of wrapping it in a new, less specific one. In an interview, walking through the counter, the class list, the backoff and the single `ensure` is usually what distinguishes an answer that has been used in production from one read in a book.

  • Why must the attempt counter be initialised outside the begin block?
    `retry` restarts the protected body from its first line. If `attempts = 0` is inside the body, it runs again on every retry, the counter never passes 1 and the limit check never fires, so the call is retried forever. Assigned before `begin`, the local keeps its value across retries.
  • Does ensure run after each failed attempt?
    No. `retry` stays inside the same `begin` block, so control never leaves it between attempts. `ensure` runs once, when the block finally completes or the last exception propagates. Per-attempt cleanup has to be written in the rescue clause before `retry`.
  • Where is retry legal, and what happens elsewhere?
    Only inside a `rescue` clause, whether that clause belongs to a `begin` block, a method body or a `do...end` block. Writing `retry` in the body, in `else`, in `ensure` or outside any handler is a `SyntaxError` reported when the file is parsed, not a runtime error.

Like redialling a busy number: every attempt starts again from the first digit, not from where the line dropped, and you tally attempts on a note kept beside the phone, not on one you rewrite each time you dial.

saying these in an interview costs you the question

  • retry resumes execution at the line that raised
  • retry gives up by itself after a few attempts
  • ensure runs after every failed attempt
  • retry may be used anywhere inside a begin block
  • Resetting the counter at the top of the begin body is fine
  • Any exception is worth retrying with a bare rescue