Rails 404, 406 и хак с X-Cascade: как точка в URL выбирает код ответа
На прошлой неделе я написал в твиттере, что если попросить у Rails .php в URL, то получишь 406, а не 404, и что мой коллега принял это за стандарт HTTP. Это не стандарт. Это решение Rails из 2006 года, и стоит оно на трёх отдельных механизмах, которые решают, что значит «не найдено».
Rails гордится принципом convention over configuration, но чтобы показать одну красивую страницу 404, нужны все три. Их добавляли с разницей в годы, а общаются они между собой через HTTP-заголовок и подмену пути.
В твите упоминался Rails 8.1.4. Основной эксперимент ниже запускался на Rails 6.1.7.6 - это приложение, которое я отлаживал. Проверки форматов я повторил на чистом приложении Rails 8.1.4 с Rack 3.2.8. Результаты вынесены в отдельный раздел, и один из них меняет совет про X-Cascade дальше в посте.
Что на самом деле говорит HTTP
В HTTP нет понятия расширения файла. Точка в пути ничего не значит, а /report.php - это просто строка, которая идентифицирует ресурс.
Формат ответа выбирается через согласование контента, в основном по заголовку Accept. Два кода ответа означают разное:
- 404: у сервера сейчас нет представления этого ресурса, или он не хочет признаваться, что оно есть.
- 406: ресурс существует, но ни одно представление не подходит под то, что клиент запросил в заголовках согласования.
Так что 406 на /wp-login.php для клиента с Accept: text/html под определение не подходит. Клиент попросил HTML, и никто ни о чём не договаривался.
Со стороны Rails эта история начинается с коммита 7611fa6d46 David Heinemeier Hansson, декабрь 2006:
Добавлен Request#format, который возвращает формат запроса как mime-тип. Если формат не указан, используется первый тип из Request#accepts.
Вся фича уместилась в одну строку:
parameters[:format] ? Mime::Type.lookup_by_extension(parameters[:format]) : accepts.first
С тех пор расширение в URL важнее заголовка. Для 2006 года идея была хорошая: браузеры слали бесполезные значения Accept, а /posts/5.xml был приятным способом попросить XML. 406 появился поверх этого позже: сегодня ActionDispatch::ExceptionWrapper.rescue_responses сопоставляет ActionController::UnknownFormat с :not_acceptable, так что суффикс в URL может закончиться кодом ответа, который определён в терминах заголовков.
Эксперимент
Чтобы всё это увидеть, хватит одного файла. Нужен gem install rails -v 6.1.7.6, потом ruby app.rb:
gem 'rails', '6.1.7.6'
require 'action_controller/railtie'
require 'rack/mock'
class MissingRecord < StandardError; end
class DemoApp < Rails::Application
config.eager_load = false
config.hosts.clear
config.secret_key_base = 'x'
config.logger = Logger.new(nil)
config.consider_all_requests_local = false
config.action_dispatch.show_exceptions = true
config.action_dispatch.rescue_responses['MissingRecord'] = :not_found
routes.append do
get '/things/:id', to: 'things#show'
get '/strict/:id', to: 'things#show', id: /[^\/]+?/, format: /[a-z]+/
get '/htmlonly/:id', to: 'things#html_only'
end
end
class ThingsController < ActionController::Base
def show
raise MissingRecord unless params[:id].start_with?('1')
render plain: "id=#{params[:id].inspect} format=#{params[:format].inspect}"
end
def html_only
respond_to { |f| f.html { render plain: 'html page' } }
end
end
DemoApp.initialize!
%w[/things/1 /things/1.37054 /things/1.php /things/6.37054 /htmlonly/1 /htmlonly/1.json /htmlonly/1.php
/strict/1.37054 /strict/6.37054 /nothing/here].each do |path|
status, _, body = DemoApp.call(Rack::MockRequest.env_for(path, 'HTTP_ACCEPT' => 'text/html'))
text = +''
body.each { |chunk| text << chunk }
puts "#{path.ljust(20)} #{status} #{text}"
end
p DemoApp.routes.call(Rack::MockRequest.env_for('/nothing/here')).first(2)
| Запрос | Результат | Почему |
|---|---|---|
/things/1 |
200, id="1", format=nil |
штатный случай |
/things/1.37054 |
200, id="1", format="37054" |
всё после последней точки считается форматом, даже цифры. Ошибки нет, просто молча неправильно |
/things/1.php |
200, format="php" |
то же самое, .php - всего лишь имя формата |
/things/6.37054 |
404 | id стал 6, а записи 6 нет |
/htmlonly/1 |
200 | штатный случай |
/htmlonly/1.json |
406, тело {"status":406,"error":"Not Acceptable"} |
в respond_to нет ветки json, поэтому бросается UnknownFormat |
/htmlonly/1.php |
406, пустое тело | то же исключение, но .php не зарегистрирован как MIME-тип |
/strict/1.37054 |
200, id="1.37054", format=nil |
цифры здесь не формат, поэтому точка остаётся в id |
/strict/6.37054 |
404 | action получил полный 6.37054, не сработал только поиск записи |
/nothing/here |
404 | нет маршрута |
routes.call(env) для /nothing/here |
404, {"X-Cascade"=>"pass"} |
сам router ничего не бросает, он отвечает «pass» |
Обратите внимание на две строки про /things. Артикул вроде 6.37054 маршрут по умолчанию не отклоняет. Он режет его пополам, и ваш action радостно ищет запись 6. Если такая запись существует, пользователь получит не ту страницу с кодом 200.
Ещё я попробовал action вообще без respond_to, только с show.html.erb, потому что .php ведёт себя менее последовательно, чем выходило из моего твита:
| Запрос | Результат | Почему |
|---|---|---|
/pages/1.php, неявный render |
200, HTML-страница | формат неизвестен, поэтому поиск шаблона не сужается |
/pages/1.json, неявный render |
406 | json - известный формат, для которого нет шаблона |
/pages/1.json, явный render 'show' |
500 | ActionView::MissingTemplate нет в сопоставлениях для 404 и 406 |
То есть URL одного и того же вида даёт 200, 406 или 500 в зависимости от того, как написан action, и ни один из трёх вариантов не 404.
А пустое тело для .php? PublicExceptions смотрит на формат запроса, не находит ничего, что умеет сериализовать, пробует public/406.html, а этот файл Rails не генерирует. Тогда он возвращает X-Cascade: pass, и этот заголовок - следующая тема.
Те же проверки на Rails 8.1.4
Я сгенерировал чистое приложение Rails 8.1.4 (Rack 3.2.8, Ruby 3.2.3) с пятью маршрутами и четырьмя контроллерами и запросил каждый URL через ActionDispatch::Integration::Session:
implicit:def show; endи толькоshow.html.erbexplicit:respond_to { |format| format.html }legacy:respond_to :html, :js, :jsonна уровне класса иrespond_withиз гемаresponderslowercase: implicit-контроллер на маршруте сid: /[^\/]+?/, format: /[a-z]+/strict:id: /[^\/]+?/, format: false, а action бросаетActionController::RoutingErrorдля неизвестных id
| Запрос | Статус | Почему |
|---|---|---|
/implicit/1 |
200 | обычный случай |
/implicit/1.php |
200 | незарегистрированный формат, поиск шаблона не сужается |
/implicit/1.xml, /implicit/1.json |
406 | зарегистрированный формат без шаблона |
/implicit/1 с Accept: application/json |
406 | заголовок даёт тот же ответ, что и суффикс |
/explicit/1.php, /explicit/1.json |
406 | в respond_to для него нет ветки |
/legacy/1.php, /legacy/1.xml |
406 | нет в объявленном списке |
/legacy/1.json |
200, application/json |
единственный объявленный формат, который работает |
/lowercase/6.37054, /lowercase/000302.B |
200 | точки остаются в id |
/lowercase/1.php |
200 | это по-прежнему формат, рендер неявный |
/strict/6.37054 |
200 | известный id |
/strict/1.php |
404 | суффиксу нечем стать форматом, поэтому id равен 1.php, и action бросает RoutingError |
/nothere/1 |
404 | нет маршрута |
Так что твит верен и на 8.1.4, но со сноской: .php даёт 406, как только action объявляет свои форматы (respond_to или respond_with), и 200, когда action рендерит неявно. Ничего нового по сравнению с 6.1 здесь нет.
Три механизма для одного «не найдено»
Router. Когда ничего не совпало, ActionDispatch::Journey::Router#serve заканчивается вот этой строкой и ничего не бросает:
[404, { "X-Cascade" => "pass" }, ["Not Found"]]
Исключения. Контроллер бросает ActiveRecord::RecordNotFound или что-то ещё. ActionDispatch::ShowExceptions его ловит, спрашивает у ExceptionWrapper статус, переписывает PATH_INFO на "/404", меняет метод запроса на GET и вызывает exceptions app с изменённым env. Между первыми двумя механизмами есть прослойка: ShowExceptions понимает только исключения, поэтому DebugExceptions проверяет каждый ответ на X-Cascade: pass и превращает его в ActionController::RoutingError. Это то самое «No route matches», которое вы знаете по логам. Его производит middleware, читающий заголовок, а не router.
config.exceptions_app. Значение по умолчанию - ActionDispatch::PublicExceptions.new(Rails.public_path). Он отдаёт public/404.html и public/500.html для HTML и небольшое тело для форматов, которые умеет сериализовать, например JSON.
Вместе, с собственным exceptions app, это выглядит так:
sequenceDiagram
autonumber
participant C as Client
participant S as ShowExceptions
participant D as DebugExceptions
participant R as Router
participant A as Controller
participant E as exceptions_app
participant X as ErrorsController
C->>S: GET /products/1/nope
S->>D: call(env)
D->>R: call(env)
alt no route matches
R-->>D: 404 + X-Cascade: pass
Note over D: pass becomes RoutingError
D--xS: raise RoutingError
else route matches
R->>A: dispatch
A--xS: raise RecordNotFound
end
Note over S: status from ExceptionWrapper<br/>PATH_INFO = /404, method = GET
S->>E: call(env)
E->>R: routes.call(env)
alt status is routed
R->>X: dispatch to not_found
X-->>E: 404 + HTML page
else status is not routed, like /500
R-->>E: 404 + X-Cascade: pass
Note over E: fallback to PublicExceptions
end
E-->>S: response
S-->>C: response
Да, router вызывается дважды. Во второй раз ваша страница ошибки - обычный маршрут, который просто назван по коду ответа.
В development config.consider_all_requests_local = true заставляет DebugExceptions рисовать свою отладочную страницу, так что запрос до exceptions_app не доходит. Локально свою страницу 404 вы не увидите, пока не переключите этот флаг.
Старый и более дырявый способ: rescue_from
Пока exceptions_app не стал обычным делом, приложения ловили ошибку в контроллере. В Spree годами жил такой хелпер:
rescue_from ActiveRecord::RecordNotFound, with: :render_404
def render_404
respond_to do |type|
type.html { render status: :not_found, file: "#{Rails.root}/public/404.html", layout: nil }
type.all { render status: :not_found, nothing: true }
end
end
render nothing: true удалили в Rails 5.1. Но удалили - не значит, что он теперь вызывает ошибку. На Rails 6.1 эта опция молча игнорируется, и render откатывается к поведению по умолчанию: к собственному шаблону action. А этот шаблон ожидает ту самую запись, которую только что не нашли.
sequenceDiagram
autonumber
participant C as Client
participant R as Router
participant A as OrdersController
participant H as render_404
participant T as show.json template
participant S as ShowExceptions
C->>R: GET /orders/1.json
R->>A: dispatch to show
A--xH: rescue_from RecordNotFound
H->>H: respond_to picks type.all
Note over H: nothing: true is ignored
H->>T: default render
T--xS: NoMethodError on nil record
S-->>C: 500
Я воспроизвёл это на 6.1.7.6: NoMethodError для number на nil, статус 500, и для .json, и для .php. Ветка HTML продолжает работать, так что никто ничего не замечает, пока не откроет трекер ошибок. Обработчик «не найдено» падает, обрабатывая «не найдено».
Upstream к тому времени уже ушёл вперёд. Spree сначала переключил эту ветку на head :not_found, объявил хелпер deprecated и удалил его в коммите 704baa4, май 2018, это вошло в 3.7. Solidus удалил его в PR #2329, октябрь 2017, вошло в 2.5, с лучшим однострочным изложением всего этого поста:
Rails умеет рендерить 404, так что это не нужно и может скрывать ошибки.
Ловушка выживает в долгоживущих форках, где копия старого хелпера переживает опцию, от которой зависит.
Что router делает с точкой
Руководство по роутингу об этом предупреждает:
По умолчанию динамические сегменты не принимают точки, потому что точка используется как разделитель для маршрутов с форматом.
Дальше оно предлагает ограничение id: /[^\/]+/. Это чинит id, но такой жадный (greedy) regex съедает и формат, так что /greedy/1.json даёт id="1.json". Если вы хотите, чтобы .json продолжал работать, нужны одновременно ленивый (lazy) id и ограничение на формат. По отдельности ни то ни другое не помогает: с одним только ленивым id 6.37054 снова разрезается.
flowchart TD
U["GET /things/6.37054"] --> Q{"Route definition"}
Q -->|"default"| A["id = 6<br/>format = 37054"]
A --> A2["Wrong record or 404"]
Q -->|"id: greedy regex"| B["id = 6.37054<br/>format = nil"]
B --> B2["Works, but .json is eaten too"]
Q -->|"id: lazy regex<br/>format: a-z only"| C{"Is the last suffix<br/>lowercase letters?"}
C -->|"yes, .php or .json"| D["id = name before the dot<br/>format = suffix"]
C -->|"no, 37054 or B"| E["id = whole string<br/>format = nil"]
Здесь меня удивили две вещи. Во-первых, format: /[a-z]+/ не превращает /strict/1.37054 в промах по маршрутам. Сначала я этого и ждал, но ленивый id просто растёт, пока не совпадёт вся строка, а необязательный формат остаётся пустым. Во-вторых, ограничение не избавляет от .php, который по-прежнему валидный формат из строчных букв. /strict/wp-login.php даёт id="wp-login" и format="php". А id со строчным суффиксом, например 000302.b, всё равно будет разрезан. Ограничение подходит для идентификаторов, которые заканчиваются цифрами или заглавными буквами, а не для произвольных строк.
Если формат в ваших URL никогда не нужен, проще format: false из раздела про format-сегменты. С id по умолчанию /nof/1.php становится чистым routing 404, потому что поглотить суффикс больше нечему. Из всех вариантов здесь только этот действительно делает .php кодом 404 на уровне router.
Хак с X-Cascade
Обычный совет для красивой страницы ошибки - config.exceptions_app = routes плюс маршрут для /404. Это рецепт от сообщества, в руководствах я его найти не смог: они упоминают только значение по умолчанию. Для 404 он работает, но у него есть тихий отказ: для статуса, на который вы не завели маршрут, routes.call(env) отвечает 404 с X-Cascade: pass, ShowExceptions заменяет это пустым ответом, и ваш public/500.html пропадает. В моём тестовом приложении /boom со статической страницы превратился в 500 с пустым телом.
Так что приложению нужен fallback:
config.exceptions_app = lambda do |env|
response = routes.call(env)
response[1]['X-Cascade'] == 'pass' ? ActionDispatch::PublicExceptions.new(Rails.public_path).call(env) : response
end
Именно эта строка меня и напрягает. Она почти дословно повторяет show_exceptions.rb:54:
response[1]["X-Cascade"] == "pass" ? pass_response(status) : response
На Rails 8.1.4 та же строка выглядит так: response[1][Constants::X_CASCADE] == "pass". Это отличие важно, дальше станет понятно почему.
Откуда вообще взялся этот заголовок? Идея старше страниц ошибок в Rails: «это приложение отказывается, спросите следующее». В Rack::Cascade та же идея: он перебирает приложения по очереди, пока одно не ответит. Забавно, что сам Rack::Cascade X-Cascade вообще не читает, он смотрит только на статус (по умолчанию 404 и 405). Заголовок - это соглашение между роутерами: его использует Rails, и Sinatra тоже выставляет его для запросов без совпадений.
Эту прослойку написал José Valim за один день, 2011-12-16. Коммит deef8dd68 вынес PublicExceptions в Rack-приложение, и страницы ошибок стало можно заменять. Коммит 535926269 научил ShowExceptions понимать «pass» от этого приложения, а PublicExceptions стал возвращать его при отсутствии файла. Тот же коммит удалил комментарий над проверкой в DebugExceptions:
# TODO: Maybe this should be in the router itself
Комментарий исчез. Проверка осталась на месте и на 6.1 всё ещё там. Сам ShowExceptions появился в 2009, автор - Joshua Peek.
Каждый шаг был разумным. А в сумме сигнал уровня Rack между middleware протекает в config/application.rb, и фреймворк, который одной командой генерирует целый CRUD, просит вас вручную лезть по индексу в кортеж ответа. Есть даже практическая цена, и на этот раз я её измерил. Rack 3 требует имена заголовков строчными. На Rails 8.1.4 с Rack 3.2.8 роутер отвечает 404 с заголовком x-cascade, ActionDispatch::Constants::X_CASCADE равна "x-cascade", а response[1]['X-Cascade'] возвращает nil. Лямбда выше по-прежнему запускается, но сравнение никогда не срабатывает, поэтому она отдаёт голый 404 роутера вместо PublicExceptions, и public/500.html тихо пропадает после обновления. Используйте ActionDispatch::Constants::X_CASCADE. В Rails 8.1.4 она есть, в 6.1.7.6 нет, а в каком релизе она появилась, я не проверял.
Более приятный API был бы маленьким: config.exceptions_app = routes, который сам откатывается на public/*.html для каждого статуса. По issue tracker в upstream видно, что в этом углу неуютно: cookies на страницах 404, невалидные MIME-типы с exceptions_app = self.routes, UnknownHttpMethod.
Рецепт
Вот к чему я в итоге пришёл. Рецепт я запускал на 6.1.7.6, а заголовок и поведение форматов проверил на 8.1.4.
-
Перестаньте неправильно разбирать идентификаторы. Форматом считаются только строчные буквы:
get '/products/:product_id/:id', to: 'variants#show', id: /[^\/]+?/, format: /[a-z]+/ -
Ищите запись по каноническому slug или нормализованному id (
6.37054в6-37054) и отвечайте 301 на канонический URL, чтобы страница не индексировалась дважды. -
Удалите
render_404и всеrescue_fromдля ошибок «не найдено». -
Рендерите все 404 в одном месте, а для остального оставьте
PublicExceptionsчерез лямбду выше (на Rails 8 сравнивайте сActionDispatch::Constants::X_CASCADE, а не со строкой):match '/404', to: 'errors#not_found', via: :allclass ErrorsController < ActionController::Base def not_found return head :not_found if request.format.json? || request.format.js? render formats: :html, status: :not_found end endИменно
formats: :htmlпозволяет.xmlили.webmanifestполучить обычную страницу.request.formatпо-прежнему отражает исходный запрос, а вrequest.original_fullpathлежит исходный URL. Вам может встретиться совет отключить здесь защиту от подделки запросов, потому что исходный запрос может быть POST. На 6.1.7.6 мне это не понадобилось:ShowExceptionsпереписывает метод наGET, и POST без токена получил обычную страницу 404. -
Сопоставьте случайно отсутствующие шаблоны с 404:
config.action_dispatch.rescue_responses['ActionView::MissingTemplate'] = :not_foundЭто меняет 500 на 404, так что из-за действительно пропавшего шаблона больше никого не поднимут алертом. Решите, готовы ли вы с этим жить.
-
Решите, что должен означать
.phpу существующей записи. С шагами выше трафик сканеров вродеwp-login.phpполучает обычный 404, потому что поиск записи падает раньше, чем запускаетсяrespond_to. Но/products/1/6.37054.phpдля настоящей записи - всё ещё 406 с пустым телом. Я пробовалbefore_action, который отвечает пораньше, но он заодно менял вывод.jsonу отдельных записей, а list-эндпоинты оставались прежними, так что я от него отказался. Однострочная альтернатива такая:config.action_dispatch.rescue_responses['ActionController::UnknownFormat'] = :not_foundЭто работает, но тогда настоящий
Accept: application/jsonна странице только с HTML тоже получит 404, а это был единственный случай, где 406 был правильным. Я оставил поведение по умолчанию. Сканеры редко угадывают настоящий артикул.
Так это стандарт HTTP?
Нет. HTTP ничего не говорит о расширениях, а его 406 - про заголовки согласования. Rails считает суффикс URL результатом согласования, потому что в 2006 году это было полезно. Соглашение до сих пор хорошо работает для того случая, под который создавалось, то есть для .json и .xml. Странности начинаются, только когда точка означает что-то другое, например артикул или сканер, который ищет PHP.
Что я из этого вынес: читайте код ответа как след того, какой дорогой прошёл запрос. 404 может быть промахом по маршрутам, брошенным исключением или неудачным поиском по обрезанному id. 406 почти всегда означает формат, который action не умеет отдавать. 500 на отсутствующей записи значит, что кто-то всё ещё обрабатывает ошибки вручную.
Ссылки
- Руководство Rails по роутингу: Dynamic Segments, Format Segments
- Руководство Rails по конфигурации,
config.exceptions_app - API:
ActionController::MimeResponds,ActionDispatch::PublicExceptions,ActionDispatch::ShowExceptions Rack::Cascade- RFC 9110, HTTP Semantics
- Коммиты Rails: 7611fa6d46 (
Request#format, 2006), 11af089ce (ShowExceptions, 2009), deef8dd68 и 535926269 (exceptions app иX-Cascade, 2011) - Коммит Spree 704baa4 и Solidus PR #2329
- Твит, с которого всё началось