Why is Ruby's Timeout.timeout risky around code that holds connections or runs ensure blocks, and what do you use instead?
answer
- a watcher thread calls Thread#raise
- can fire on any line, even ensure
- Timeout::ExitException inside the block
- Timeout::Error < RuntimeError outside
- open_timeout, read_timeout, IO#timeout=
basics
~20 sTimeout.timeout makes a watcher thread raise into your thread with Thread#raise, so the exception can land on any line: mid-write, inside ensure cleanup, while a lock is held. Prefer the timeouts built into the I/O call itself.
solid answer
~40 s`Timeout.timeout(sec) { ... }` registers a deadline with a shared background thread; when it passes, that thread calls `Thread#raise` on yours. The exception is **asynchronous**: it can interrupt any Ruby line, including half-way through writing a request, in the middle of a connection pool's checkout, or inside an `ensure` block that was releasing a resource. The block may also rescue it and carry on, so the limit is not enforceable. The damage is corrupted state, not just a slow call: a connection with half a request written returned to a pool. Prefer timeouts the I/O layer enforces: `Net::HTTP`'s `open_timeout`, `read_timeout` and `write_timeout`, `IO#timeout=` (raises `IO::TimeoutError`, Ruby 3.2+), `Socket.tcp(open_timeout:)` (Ruby 4.0), and database drivers' statement timeouts. If you must keep `Timeout`, put cleanup outside the block.
code
ruby · 13 linesrequire "net/http"
# Risky: the timeout can fire while the pool checkout or the write is half done
Timeout.timeout(5) do
pool.with { |http| http.request(Net::HTTP::Get.new("/rates")) }
end
# Safer: the I/O layer enforces the limits at clean points
Net::HTTP.start("rates.example", 443, use_ssl: true,
open_timeout: 2, read_timeout: 5, write_timeout: 5) do |http|
http.get("/rates")
end
# raises Net::OpenTimeout or Net::ReadTimeout (both Timeout::Error subclasses)go deeper
Recall that Timeout.timeout raises Timeout::Error when a block runs too long, and that it interrupts the block from another thread.
Explain Thread#raise delivery, why it can hit ensure blocks, and the ExitException versus Timeout::Error split for bare rescue.
Show you would replace block-level timeouts with Net::HTTP, IO#timeout= and driver timeouts, and discard connections touched by an interrupted block.
Set a timeout policy for a service: which layer owns each deadline, how budgets nest across calls, and how interrupted work is detected and cleaned up.
## How Timeout.timeout works `Timeout` is a default gem in Ruby 4.0 (version 0.6.0). A call such as `Timeout.timeout(2) { fetch_rates }` does this: 1. It ensures one shared watcher thread, named `Timeout stdlib thread`, is running. 2. It enqueues a request holding your thread and a monotonic deadline. 3. It runs your block normally. 4. If the block finishes first, the request is marked done and the block's value is returned. 5. If the deadline passes first, the watcher calls **`Thread#raise`** on your thread. When a Fiber scheduler is active, the call delegates to the scheduler's `timeout_after` hook instead, but the effect on your code is the same: an exception appears at an arbitrary point. ## Why asynchronous interruption is dangerous `Thread#raise` delivers the exception at the next point where the thread checks for interrupts, which can be almost any Ruby operation. That produces failure modes a normal exception cannot: - **Half-finished writes**: a request partly written to a socket, then the connection is reused and the server reads garbage. - **Interrupted cleanup**: an `ensure` block that returns a connection to a pool or releases a lock is itself interrupted, leaking the resource. - **Inconsistent objects**: an instance variable updated, its partner not yet updated. - **Swallowed timeouts**: a `rescue Exception` or a retry loop inside the block can catch the timeout and keep going, so the limit is advisory. - **Uninterruptible native code**: a C call that holds the GVL and never checks for interrupts runs to completion regardless of the deadline. The timeout gem's own documentation states that it cannot be relied on to enforce timeouts for untrusted blocks. ## The exception classes | Where | Class | Superclass | Caught by bare `rescue`? | |---|---|---|---| | Inside the block, no class argument | `Timeout::ExitException` | `Exception` | no | | Outside, after `Timeout.timeout` returns control | `Timeout::Error` | `RuntimeError` | yes | | Inside and outside, when you pass `klass` | your class, for example `Net::OpenTimeout` | your choice | depends | Raising an `Exception` subclass inside the block stops a stray `rescue => e` from swallowing the timeout; `Timeout.timeout` translates it to `Timeout::Error` once it reaches the call site. Passing your own class turns that protection off. ## Safer alternatives Bound the operation where it blocks, so the error is raised at a clean point in the I/O code: - **`Net::HTTP`**: `open_timeout`, `read_timeout` and `write_timeout` (all 60 seconds by default; the read and write limits apply to each blocking call, not the whole request), for example `Net::HTTP.start(host, 443, use_ssl: true, open_timeout: 3, read_timeout: 5)`. Failures raise `Net::OpenTimeout` or `Net::ReadTimeout`. - **Raw IO**: `io.timeout = 5` (Ruby 3.2+) makes blocking reads and writes on that IO raise `IO::TimeoutError`. - **Sockets**: since Ruby 4.0, `Socket.tcp(host, port, open_timeout: 3)` and `TCPSocket.new` accept `open_timeout:`. - **Databases and HTTP client gems**: their own connect and statement timeouts, which the server or driver enforces. - **Regular expressions**: `Regexp.timeout` for runaway matching. ## If you must use Timeout - Wrap the smallest possible call, never a block that checks out and returns pooled resources. - Keep cleanup outside: `begin; Timeout.timeout(sec) { op }; ensure; cleanup; end`. - When `ensure` blocks inside the operation must not be interrupted, use `Thread.handle_interrupt(Timeout::Error => :never)` around the call and `:immediate` around the interruptible part, passing `Timeout::Error` as the class, as the gem documents. - Treat any connection used inside an interrupted block as broken: close it rather than returning it to the pool. ## Choosing where each deadline lives A useful review question for any `Timeout.timeout` call is: which operation is actually slow? The answer is almost always one of a handful of blocking calls, and each has a narrower limit: | Slow operation | Bound it with | Error raised | |---|---|---| | TCP connect | `open_timeout` on `Net::HTTP`, `Socket.tcp`, `TCPSocket.new` | `Net::OpenTimeout` or `IO::TimeoutError` | | waiting for response bytes | `read_timeout` on `Net::HTTP` | `Net::ReadTimeout` | | a stuck write | `write_timeout` on `Net::HTTP`, `IO#timeout=` | `Net::WriteTimeout` or `IO::TimeoutError` | | a slow SQL statement | the driver's or server's statement timeout | the driver's error class | These per-call limits do not cap the total time of a multi-step job, so an overall budget may still be needed; compute a deadline once and pass the remaining time into each call rather than wrapping the whole job in `Timeout.timeout`.
- Why does a bare rescue inside a Timeout.timeout block not catch the timeout in Ruby 4.0?Without a class argument, the exception raised inside the block is `Timeout::ExitException`, a direct `Exception` subclass, and bare `rescue` catches only `StandardError`. `Timeout.timeout` converts it to `Timeout::Error` (a `RuntimeError`) at the call site. Passing a class such as `Timeout::Error` as the second argument removes that protection.
- How can you stop Timeout from interrupting an ensure block inside the operation?Wrap the call in `Thread.handle_interrupt(Timeout::Error => :never)`, pass `Timeout::Error` as the class to `Timeout.timeout`, and re-enable interruption with `Thread.handle_interrupt(Timeout::Error => :immediate)` only around the work that may be cut short. Cleanup then runs uninterrupted, at the cost of possibly exceeding the deadline.
saying these in an interview costs you the question
- Timeout.timeout stops the block cleanly at the next safe point.
- Timeout.timeout starts a new thread for every call it makes.
- An ensure block is always protected from Timeout interrupts.
- rescue => e inside the block will catch Timeout's exception.
- Timeout.timeout guarantees the block never runs past its deadline.