A Rack 2 middleware breaks after upgrading to Rack 3; what did the Rack 3 SPEC change about status, headers and the response Array?
answer
- status must be an Integer
- header names all lower-case
- multiple values as an Array, no newline
- response Array and headers Hash unfrozen
- rackup and Rack::Session left the gem
basics
~20 sRack 3 requires an Integer status of at least 100, a mutable Hash of headers with lower-case names and Array values for repeated headers, and a non-frozen response Array; rackup, Rack::Server and Rack::Session also moved into separate gems.
solid answer
~40 sThe Rack 3 SPEC tightened the response. The status must be an Integer of at least 100, so `"200"` fails. Headers must be an unfrozen `Hash` - not an Array of pairs - with keys containing no uppercase letters, so a middleware reading `headers["Content-Type"]` gets `nil`; use `"content-type"` or `Rack::Headers`, which lower-cases keys (`Rack::Utils::HeaderHash` was removed in 3.1). A repeated header such as `set-cookie` is an Array of Strings instead of one String joined with `\n`. The response Array itself must not be frozen. Around the SPEC, `rackup`, `Rack::Server` and `Rack::Handler` moved to the `rackup` gem, `Rack::Session` to `rack-session`, and `rack.input` is no longer rewindable. Wrapping the suite in `Rack::Lint` finds each violation.
code
ruby · 11 lines# Rack 2 habit - fails Rack::Lint on Rack 3
NOT_FOUND = [404, {"Content-Type" => "text/plain"}, ["Not Found"]].freeze
# Rack 3
def not_found
[404, {"content-type" => "text/plain"}, ["Not Found"]]
end
headers = Rack::Headers.new
headers["Set-Cookie"] = ["a=1", "b=2"]
headers.keys # => ["set-cookie"]go deeper
Recall the three response rules: Integer status, lower-case header names, and a normal mutable Array and Hash rather than frozen ones.
Explain each rule with the code it breaks: capitalised header lookups returning nil, newline-joined cookies, frozen response constants, and the gems that moved out.
Plan the upgrade: wrap the suite in Rack::Lint, grep for the old idioms, add the rackup and rack-session gems, and handle non-rewindable rack.input in body-reading middleware.
Weigh supporting Rack 2 and 3 at once through the SPEC's strict intersection against a clean cut-over, and decide how much in-house middleware to replace with maintained gems.
## Why middleware breaks on upgrade Rack 3.0 (2022) changed the SPEC in ways that old code notices. Apps built on a framework mostly upgrade with it; **hand-written middleware** and small Rack apps are where the breakage shows up, because they touch the raw triple. The body changes (streaming bodies, `to_ary`) are a topic of their own; this answer covers status, headers and the Array. ## The response rules that changed | Rule | Rack 2 habit | Rack 3 requirement | |---|---|---| | Status | anything with `to_i`, e.g. `"200"` | an `Integer` >= 100 | | Headers container | any object whose `each` yields pairs | an **unfrozen `Hash`** | | Header names | `Content-Type`, mixed case | **no uppercase ASCII letters**: `content-type` | | Repeated header | one String joined with `"\n"` | an **Array of Strings** | | Response Array | could be a frozen constant | **must not be frozen** | The lower-case rule is the one that bites most. Code that did `headers["ETag"]` silently gets `nil` because the app now writes `"etag"`; code that writes `headers["X-Frame-Options"]` fails `Rack::Lint` with `uppercase character in header name`. Two fixes: 1. Use lower-case String keys everywhere - the simplest, and what the shipped middleware does. 2. Use **`Rack::Headers`**, a `Hash` subclass that lower-cases keys on write and lookup, so `h["Foo"] = "bar"` is readable as `h["FOO"]` and `h.keys` is `["foo"]`. `Rack::Utils::HeaderHash`, the Rack 2 helper for case-insensitive access, was deprecated and then **removed in 3.1**. For repeated headers, a value may be an Array; `Rack::Response#add_header` promotes a String to an Array when a second value arrives. Values must not contain `\0`, `\r` or `\n`, so the old newline-joined form is now invalid. ## The frozen-constant trap A common Rack 2 idiom was: ```ruby NOT_FOUND = [404, {}, ["Not Found"]].freeze ``` Rack 3 rejects it (`Rack::Lint`: `response is frozen`). The upgrade guide also points at a subtle bug the rule exposes: `freeze` is **shallow**, so the headers Hash inside stayed mutable, and a middleware adding a header to it changed the shared constant for every later request. Return a fresh Array from a method instead. ## What moved out of the rack gem - **`rackup`**, `Rack::Server`, `Rack::Handler` and `Rack::Lobster` went to the **`rackup` gem** as `Rackup::Server`, `Rackup::Handler` and `Rackup::Lobster`. Add `gem "rackup"` if you start servers through it. - **`Rack::Session`** went to the **`rack-session` gem**. - `config.ru` no longer has Rack's modules loaded for it; add `require "rack"`. - `Rack::File` became `Rack::Files`; the deprecated alias was removed in 3.1. `Rack::Request#[]` was also removed in 3.1 in favour of `request.params[key]`, and 3.2 removed `Rack::Logger` and `Request#values_at`. ## Request-side changes that affect middleware - **`rack.input` is no longer rewindable**, and Rack no longer rewinds it after parsing form data. Middleware that reads the body and expects the app to read it again should add `Rack::RewindableInput::Middleware` and call `rewind` explicitly. - `rack.version`, `rack.multithread`, `rack.multiprocess` and `rack.run_once` are no longer required env keys; do not branch on them. - `SERVER_PROTOCOL` became a required key. - Rack 3.2 made **String env keys** a SPEC rule. ## Supporting Rack 2 and 3 during a migration A gem that must run on both versions can stay inside what the upgrade guide calls the strict intersection: - write lower-case header names, which Rack 2 accepted as well; - return Integer statuses and fresh, unfrozen Arrays and Hashes; - use `Rack::Response#add_header` for repeated headers instead of choosing between an Array and a newline-joined String; - pick a headers class with `defined?(Rack::Headers) ? Rack::Headers.new : {}`, as the upgrade guide suggests. ## Finding the breakage The reliable method is mechanical rather than reading code: 1. Run the test suite with each app and middleware wrapped in `Rack::Lint`, or pass `lint: true` to `Rack::MockRequest#get`. 2. Fix every `Rack::Lint::LintError` - each message names the rule (`Status must be an Integer >=100`, `headers object should not be frozen, but is`). 3. Grep for capitalised header literals, `\n`-joined header values and `.freeze` on response constants. 4. Add the `rackup` and `rack-session` gems where needed. The SPEC changes were designed so that code following their strict intersection works on both Rack 2 and 3; the one change that is not backwards compatible is Array header values, which `Rack::Response#add_header` hides.
- How can a middleware read the content type of a response when it does not know whether the app followed the lower-case rule?Under Rack 3 it may assume it: the SPEC forbids uppercase header names, and `Rack::Lint` enforces that, so `headers["content-type"]` is the correct lookup. If you must tolerate non-conforming apps, copy the headers into a `Rack::Headers`, which lower-cases keys on write and lookup.
- Your middleware parses a JSON request body, then the app finds rack.input empty. What changed in Rack 3?`rack.input` is no longer required to be rewindable and Rack does not rewind it for you. After reading it the stream is at EOF. Add `Rack::RewindableInput::Middleware` above the reader so the input can be rewound, and call `rewind` after reading, or store the parsed result in `env` under your own dotted key.
saying these in an interview costs you the question
- Using Rack::Utils::HeaderHash for case-insensitive headers on Rack 3.2
- Joining two set-cookie values with a newline in one String
- Thinking Array#freeze on the response also freezes its headers Hash
- Expecting require "rack" to provide Rack::Server in Rack 3
- Assuming rack.input can always be rewound and read again