Direct answer
Timeout::Error raised inside a thread does not reach the calling thread unless that thread is joined with Thread#join or Thread#value. Thread#kill raises Thread::Kill and discards an in-flight Timeout::Error unless the thread rescues and re-raises it. IO.select with a timeout returns nil; it does not raise, so an outer Timeout.timeout is what surfaces the error, provided the C call releases the GVL.
Confirmed behavior
- Timeout.timeout uses a watcher thread that calls Thread#raise on the thread executing the block. The exception lives in that thread's context.
- An unjoined thread that dies from Timeout::Error logs it via Thread#report_on_exception (default true since Ruby 2.5) but does not propagate it to the caller.
- Thread#kill injects Thread::Kill. If Timeout::Error is being handled in an ensure block, Thread::Kill takes precedence and the Timeout::Error is lost unless explicitly re-raised.
- IO.select([io], nil, nil, 2) returns nil after two seconds. Wrapped in Timeout.timeout(1) { ... }, the Timeout::Error interrupts the select after one second on MRI, assuming the select implementation releases the GVL while waiting.
- Timeout::Error inherits from Exception, not StandardError. A bare rescue clause will not catch it.
Likely explanation for lost errors
In event-driven servers, each request often runs in its own thread. If Timeout.timeout wraps request handling and no top-level handler catches Timeout::Error, the worker thread terminates and the error is reported to stderr, not converted to a 504. With Ruby 3.0+ fibers and a non-blocking scheduler, Timeout.timeout cannot interrupt a yielded fiber because Thread#raise targets OS threads, not fibers.
Steps for this case
- Catch Timeout::Error inside the thread body and re-raise it after join, or capture it via Thread#value.
- Catch Timeout::Error explicitly at the server's request boundary; do not rely on StandardError rescue.
- Replace Thread#kill with a cooperative shutdown flag when you need to preserve timeout semantics.
- For fiber-based servers, use the scheduler's timeout API instead of Timeout.timeout.
t = Thread.new do
begin
Timeout.timeout(2) { long_operation }
rescue Timeout::Error => e
raise
end
end
begin
t.value
rescue Timeout::Error => e
# convert to 504 or log
end
Verification
Run this on MRI 2.7+:
ruby -e 'require "timeout"; t = Thread.new { Timeout.timeout(1) { sleep 5 } }; t.value'
Expect Timeout::Error in the caller. Remove t.value and the error is only logged.
Missing diagnostic detail: Are you using MRI threads or Fibers with a non-blocking scheduler? The fiber case requires scheduler-specific timeouts.