Rack middleware¶
Rack is Ruby’s tiny contract between web servers and
apps: one call(env) method, one response tuple. TIMEx::Propagation::RackMiddleware
slides into that stack so every request can carry a deadline in and,
when you ask for it, echo remaining time on the way out—without each
controller re-parsing headers by hand.
Mental model: the middleware is the bouncer at the door. It reads the sticky
note on the request (X-TIMEx-Deadline), decides if you are
already too late to enter, and if not it hands the Deadline to the rest of your
app via env.
Trust boundary: the inbound header is untrusted on the public internet—pair
max_seconds: / max_depth: with network controls the way you would any other
user-supplied budget knob.
Drop-in setup¶
# config.ru
require "timex"
use TIMEx::Propagation::RackMiddleware,
default_seconds: 30,
max_seconds: 30, # clamp untrusted inbound budgets (do this on public edges)
max_depth: 8, # reject runaway hop counts
expose_remaining: true # echo X-TIMEx-Remaining-Ms on success responses
run MyApp
default_seconds: — optional. When the client sends no header, TIMEx
creates a fresh budget of that many seconds so env["timex.deadline"] is still
set. Omit it if you only want deadlines when callers opt in.
header_case: — :rack3 (default, lower-case response keys) or :canonical
(X-TIMEx-…) for older stacks that expect mixed-case headers.
clamp_infinite_to_default: — when true and default_seconds is set, an
inbound ms=inf header is replaced with the default budget instead of being honored.
Pair with max_seconds: on public edges so a misconfigured (or hostile) caller
cannot opt out of your timeout policy.
What it does (step by step)¶
- Read
X-TIMEx-Deadlinefrom the Rack env (Rack exposes it asHTTP_X_TIMEX_DEADLINE). - Store the resulting
Deadlineatenv["timex.deadline"]when one exists—or build one fromdefault_secondswhen you configured that. - Short-circuit if the deadline is already expired: respond
503 Service Unavailable, plain body, and headerX-TIMEx-Outcome: expired-on-arrivalso load balancers and clients can tell “late” from “bug.” - Otherwise run your app.
- Response headers — enable
expose_remaining: trueto addX-TIMEx-Remaining-Ms(Rack 3 lower-case key by default) so clients see how much budget is left after your work. Outcome headers on503always includeX-TIMEx-Outcome(or canonical casing—see below).
| Step | Plain English |
|---|---|
| Parse header | Turn the wire string into a real Deadline (or nil if missing / junk). |
Attach to env |
Controllers and downstream code read request.env["timex.deadline"] in Rails. |
503 on arrival |
Do not burn CPU on a request the caller already abandoned. |
| Remaining on exit | Opt-in via expose_remaining:—off by default so you do not surprise caches. |
Using the deadline downstream¶
Once the middleware ran, treat env["timex.deadline"] like any other
Deadline: tighten with min, call check! in loops you own, pass it
into HTTP clients with HttpHeader.inject.
class WidgetsController < ApplicationController
def show
deadline = request.env["timex.deadline"] || TIMEx::Deadline.in(2.0)
TIMEx.deadline(deadline) do |d|
@widget = Widget.find_with_deadline(params[:id], deadline: d.min(0.5))
end
end
end
The || TIMEx::Deadline.in(2.0) line is a local safety net when no header and
no default_seconds were provided—tweak to match your product rules.
Real-world: one deadline, every Rails layer¶
In a real Rails app the controller, model, and any inline jobs all want the
same budget without re-parsing headers. Stash it on Current in a
before_action, and every layer reads the same object:
class Current < ActiveSupport::CurrentAttributes
attribute :deadline
end
class ApplicationController < ActionController::Base
before_action :attach_deadline
private
def attach_deadline
Current.deadline = request.env["timex.deadline"] || TIMEx::Deadline.in(2.0)
end
end
class Order < ApplicationRecord
def self.for_dashboard
TIMEx.deadline(Current.deadline) do |d|
includes(:line_items).where(state: :open).find_each(batch_size: 100) { |o| d.check!; yield o }
end
end
end
The header set by an upstream gateway flows through the controller, into the
model loop, and out to any HTTP client via
HttpHeader.inject—one budget, no surprises.