Rails 404, 406 и хак с X-Cascade: как точка в URL выбирает код ответа

Rails 404 и 406: точка в URL делит запрос, а X-Cascade передаёт его exceptions app

На прошлой неделе я написал в твиттере, что если попросить у 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.erb
  • explicit: respond_to { |format| format.html }
  • legacy: respond_to :html, :js, :json на уровне класса и respond_with из гема responders
  • lowercase: 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.

  1. Перестаньте неправильно разбирать идентификаторы. Форматом считаются только строчные буквы:

    get '/products/:product_id/:id', to: 'variants#show', id: /[^\/]+?/, format: /[a-z]+/
    
  2. Ищите запись по каноническому slug или нормализованному id (6.37054 в 6-37054) и отвечайте 301 на канонический URL, чтобы страница не индексировалась дважды.

  3. Удалите render_404 и все rescue_from для ошибок «не найдено».

  4. Рендерите все 404 в одном месте, а для остального оставьте PublicExceptions через лямбду выше (на Rails 8 сравнивайте с ActionDispatch::Constants::X_CASCADE, а не со строкой):

    match '/404', to: 'errors#not_found', via: :all
    
    class 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.

  5. Сопоставьте случайно отсутствующие шаблоны с 404:

    config.action_dispatch.rescue_responses['ActionView::MissingTemplate'] = :not_found
    

    Это меняет 500 на 404, так что из-за действительно пропавшего шаблона больше никого не поднимут алертом. Решите, готовы ли вы с этим жить.

  6. Решите, что должен означать .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 на отсутствующей записи значит, что кто-то всё ещё обрабатывает ошибки вручную.

Ссылки