Timeout Guard¶
A task calls a slow third party. The API hangs. Your worker sits there forever.
Cap wall-clock time around the task lifecycle with middleware. Runtime turns a timeout exception into a failed Result under execute — no extra rescue in your task.
Two patterns:
- Ruby stdlib
Timeout— one line, hard ceiling, known MRI caveats. - TIMEx — a shared
Deadline, cooperativecheck!, and strategies when stdlibTimeoutis too blunt.
Always set the dependency's own timeout (read_timeout, Redis :timeout, Faraday :timeout) when you can. Use middleware as an outer safety net across the whole task. TIMEx's getting started guide walks through that layering in more detail.
Ruby stdlib Timeout¶
Setup¶
require "timeout"
class CmdxTimeoutMiddleware
def initialize(seconds:, message: nil)
@seconds = seconds
@message = message
end
def call(task)
secs = resolve(task)
return yield if secs.nil?
::Timeout.timeout(secs, ::Timeout::Error, @message || "timed out after #{secs}s") { yield }
end
private
def resolve(task)
case @seconds
when nil, Numeric then @seconds
when Symbol then task.context[@seconds]
when Proc then @seconds.call(task)
else @seconds.respond_to?(:call) ? @seconds.call(task) : @seconds
end
end
end
Usage¶
class FetchReport < CMDx::Task
register :middleware, CmdxTimeoutMiddleware.new(seconds: 5)
required :report_id, coerce: :integer
def work
context.report = ReportClient.fetch(report_id, open_timeout: 1, read_timeout: 4)
end
end
result = FetchReport.execute(report_id: 42)
result.failed? # => true when the fetch exceeds 5s
result.reason # => "[Timeout::Error] timed out after 5s"
result.cause # => #<Timeout::Error: ...>
Per-call deadlines:
register :middleware, CmdxTimeoutMiddleware.new(seconds: :request_deadline)
register :middleware, CmdxTimeoutMiddleware.new(seconds: ->(t) { t.context.slo_ms / 1000.0 })
TIMEx deadline¶
TIMEx keeps a Deadline you can pass through loops and HTTP clients. Cooperative code calls deadline.check! at safe spots; nested work can share one budget instead of stacking unrelated timers.
Use on_timeout: :raise_standard in middleware so TIMEx::TimeoutError flows through Runtime like any other StandardError (stdlib Timeout::Error behaves the same way).
Setup¶
# Gemfile: gem "timex"
require "timex"
class CmdxTimexTimeoutMiddleware
def initialize(seconds:, strategy: nil, auto_check: nil)
@seconds = seconds
@strategy = strategy
@auto_check = auto_check
end
def call(task)
secs = resolve(task)
return yield if secs.nil?
opts = { on_timeout: :raise_standard }
opts[:strategy] = @strategy unless @strategy.nil?
opts[:auto_check] = @auto_check unless @auto_check.nil?
TIMEx.deadline(secs, **opts) do |deadline|
task.context.deadline = deadline
yield
end
end
private
def resolve(task)
case @seconds
when nil, Numeric then @seconds
when Symbol then task.context[@seconds]
when Proc then @seconds.call(task)
else @seconds.respond_to?(:call) ? @seconds.call(task) : @seconds
end
end
end
Usage¶
Bounded loop with cooperative checkpoints (from the TIMEx quick start):
class ScoreExportRows < CMDx::Task
register :middleware, CmdxTimexTimeoutMiddleware.new(seconds: 5.0)
required :export_id, coerce: :integer
def work
rows = ExportRow.where(export_id:)
context.scores = []
rows.find_each do |row|
context.deadline.check!
context.scores << Scorer.score(row)
end
end
end
ScoreExportRows.execute(export_id: 99)
HTTP work: shrink the client timeout to match deadline.remaining so the socket fails inside the same budget:
class FetchReport < CMDx::Task
register :middleware, CmdxTimexTimeoutMiddleware.new(seconds: 5)
required :report_id, coerce: :integer
def work
remaining = context.deadline.remaining
context.report = ReportClient.fetch(
report_id,
open_timeout: 1,
read_timeout: [remaining, 4].min
)
end
end
When work cannot call check! (legacy gem, tight loop you do not own), opt into auto-check or a stronger strategy on the middleware:
register :middleware, CmdxTimexTimeoutMiddleware.new(seconds: 5, auto_check: true)
register :middleware, CmdxTimexTimeoutMiddleware.new(seconds: 5, strategy: :io)
Notes¶
Pick stdlib vs TIMEx¶
Reach for stdlib Timeout when you only need a dumb outer fuse and accept MRI's async-interrupt trade-offs. Reach for TIMEx when you want explicit deadlines, cooperative cancellation, IO/subprocess strategies, or deadline propagation across hops.
stdlib Timeout caveats¶
On MRI, Timeout.timeout raises asynchronously inside whatever code is running — sometimes inside ensure blocks. Sockets and transactions can be left half-cleaned. Prefer library-level timeouts for resources you control; use this wrapper when you need a hard ceiling anyway.
Failed vs raised¶
Under execute, Timeout::Error and TIMEx::TimeoutError become failed Results (rollback still runs). Under execute!, the error re-raises after lifecycle finalization — strict callers can rescue directly.
Fiber scheduler alternative¶
Inside Async { ... }, the async gem's Task#with_timeout cancels cooperatively and avoids thread-level interrupts.