---
title: "Rails 404, 406 и хак с X-Cascade: как точка в URL выбирает код ответа"
aiAuthorship: 90
date: 2026-11-04T12:00:00
tags: [rails, spree, routing, http, errors, ruby]
description: "Почему запрос .php в Rails может вернуть 406 вместо 404, как ошибка на самом деле становится ответом и почему ради красивой страницы 404 конфиг приложения читает HTTP-заголовок."
embedding:
  version: 1
  model: "text-embedding-3-small"
  dimensions: 512
  hash: "sha256:0759470477fba3a9e9be13a53bdb5984786f900fdfb009ab9c4a1e325eecf39a"
  vector: "2AnZFDD42+0S/xDJ/CIO8xf64CYNzg8L9/kjLPz+++X9DRH9CAAjLC0WBQMND/b8B/0KD/Qm8fjRCxIDIunb5gEnxfQbLR7tCNUP9RgtHtb26d24D9f91wnz0ybjIvvvHxbe+CPO8uIu1e4fD+3R6uQj9+0K5QcJ5fvg2Pn4I+3v5TkaIeb++fjv6Bzt/gPp6S3qAev05BHOC9zLCf31/9YW7fYK8P0TxBH23t8H8+X81BzmBPoHEsza+RETGuUy/f0a0Q7qGizhKFHzASL6CuruFu0PHAb5C/4IGvIe9/QcKj/x6iQXG/rWJ9ziCg3zC+7tBhz269rgBjgb+Nvb5A7v1/ntDuoA7f8JBw3uDick+d7XDe3aCfTlASYVGuwlJuf2/vIGCCM27BQDEPgMCCj9C/cI5sjxuAMOqTLMG8gAxPAAA/YI7Pj969UhCgzLzxwHBeD85eTx4O/ZCyryBxUo7wj2+e0Y9gTf5RhAwOYKA/o87dAE8t4WKiQJGAP2IBv1Dsny9AAB6h0RLvEeAhQf4PgHHg8J+/QODiHj/NMFBAEMHfPRF/QmGvNP2PsMCAUL9Pj7JNUb/vED3PL24gnz8unX8tPiMfcM8QE04RYSCgH6JOMZBu7P9Q0D7Qf9EeYW2SR/wh3+EtUeCfP/+fDtENvm8/kXFeb+///aI+8="
  scale: 0.001605945
---

![Rails 404 и 406: точка в URL делит запрос, а X-Cascade передаёт его exceptions app](img/rails-404-406-and-the-x-cascade-hack-preview.jpg)

На прошлой неделе я [написал в твиттере](https://x.com/tot_ra/status/2116168495914426564), что если попросить у Rails `.php` в URL, то получишь 406, а не 404, и что мой коллега принял это за стандарт HTTP. Это не стандарт. Это решение Rails из 2006 года, и стоит оно на трёх отдельных механизмах, которые решают, что значит «не найдено».

Rails гордится принципом convention over configuration, но чтобы показать одну красивую страницу 404, нужны все три. Их добавляли с разницей в годы, а общаются они между собой через HTTP-заголовок и подмену пути.

<!-- truncate -->

В твите упоминался Rails 8.1.4. Основной эксперимент ниже запускался на Rails 6.1.7.6 - это приложение, которое я отлаживал. Проверки форматов я повторил на чистом приложении Rails 8.1.4 с Rack 3.2.8. Результаты вынесены в отдельный раздел, и один из них меняет совет про `X-Cascade` дальше в посте.

## Что на самом деле говорит HTTP

В HTTP нет понятия расширения файла. Точка в пути ничего не значит, а `/report.php` - это просто строка, которая идентифицирует ресурс.

Формат ответа выбирается через [согласование контента](https://www.rfc-editor.org/rfc/rfc9110.html#section-12), в основном по заголовку `Accept`. Два кода ответа означают разное:

- [404](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.5): у сервера сейчас нет представления этого ресурса, или он не хочет признаваться, что оно есть.
- [406](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.7): ресурс существует, но ни одно представление не подходит под то, что клиент запросил в заголовках согласования.

Так что 406 на `/wp-login.php` для клиента с `Accept: text/html` под определение не подходит. Клиент попросил HTML, и никто ни о чём не договаривался.

Со стороны Rails эта история начинается с [коммита 7611fa6d46](https://github.com/rails/rails/commit/7611fa6d46) David Heinemeier Hansson, декабрь 2006:

> Добавлен Request#format, который возвращает формат запроса как mime-тип. Если формат не указан, используется первый тип из Request#accepts.

Вся фича уместилась в одну строку:

```ruby
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`:

```ruby
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` заканчивается вот этой строкой и ничего не бросает:

```ruby
[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`.** [Значение по умолчанию](https://guides.rubyonrails.org/configuring.html#config-exceptions-app) - `ActionDispatch::PublicExceptions.new(Rails.public_path)`. Он отдаёт `public/404.html` и `public/500.html` для HTML и небольшое тело для форматов, которые умеет сериализовать, например JSON.

Вместе, с собственным exceptions app, это выглядит так:

```mermaid
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 годами жил такой хелпер:

```ruby
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](https://guides.rubyonrails.org/5_1_release_notes.html). Но удалили - не значит, что он теперь вызывает ошибку. На Rails 6.1 эта опция молча игнорируется, и `render` откатывается к поведению по умолчанию: к собственному шаблону action. А этот шаблон ожидает ту самую запись, которую только что не нашли.

```mermaid
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](https://github.com/spree/spree/pull/8465) и [удалил его в коммите 704baa4](https://github.com/spree/spree/commit/704baa4), май 2018, это вошло в 3.7. Solidus [удалил его в PR #2329](https://github.com/solidusio/solidus/pull/2329), октябрь 2017, вошло в 2.5, с лучшим однострочным изложением всего этого поста:

> Rails умеет рендерить 404, так что это не нужно и может скрывать ошибки.

Ловушка выживает в долгоживущих форках, где копия старого хелпера переживает опцию, от которой зависит.

## Что router делает с точкой

[Руководство по роутингу](https://guides.rubyonrails.org/routing.html#dynamic-segments) об этом предупреждает:

> По умолчанию динамические сегменты не принимают точки, потому что точка используется как разделитель для маршрутов с форматом.

Дальше оно предлагает ограничение `id: /[^\/]+/`. Это чинит id, но такой жадный (greedy) regex съедает и формат, так что `/greedy/1.json` даёт `id="1.json"`. Если вы хотите, чтобы `.json` продолжал работать, нужны одновременно ленивый (lazy) id и ограничение на формат. По отдельности ни то ни другое не помогает: с одним только ленивым id `6.37054` снова разрезается.

```mermaid
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-сегменты](https://guides.rubyonrails.org/routing.html#format-segments). С 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:

```ruby
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`:

```ruby
response[1]["X-Cascade"] == "pass" ? pass_response(status) : response
```

На Rails 8.1.4 та же строка выглядит так: `response[1][Constants::X_CASCADE] == "pass"`. Это отличие важно, дальше станет понятно почему.

Откуда вообще взялся этот заголовок? Идея старше страниц ошибок в Rails: «это приложение отказывается, спросите следующее». В [`Rack::Cascade`](https://rubydoc.info/gems/rack/Rack/Cascade) та же идея: он перебирает приложения по очереди, пока одно не ответит. Забавно, что сам `Rack::Cascade` `X-Cascade` вообще не читает, он смотрит только на статус (по умолчанию 404 и 405). Заголовок - это соглашение между роутерами: его использует Rails, и Sinatra тоже выставляет его для запросов без совпадений.

Эту прослойку написал José Valim за один день, 2011-12-16. [Коммит deef8dd68](https://github.com/rails/rails/commit/deef8dd68) вынес `PublicExceptions` в Rack-приложение, и страницы ошибок стало можно заменять. [Коммит 535926269](https://github.com/rails/rails/commit/535926269) научил `ShowExceptions` понимать «pass» от этого приложения, а `PublicExceptions` стал возвращать его при отсутствии файла. Тот же коммит удалил комментарий над проверкой в `DebugExceptions`:

```ruby
# TODO: Maybe this should be in the router itself
```

Комментарий исчез. Проверка осталась на месте и на 6.1 всё ещё там. Сам `ShowExceptions` [появился в 2009](https://github.com/rails/rails/commit/11af089ce), автор - 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](https://github.com/rails/rails/issues/53766), [невалидные MIME-типы с `exceptions_app = self.routes`](https://github.com/rails/rails/issues/51228), [`UnknownHttpMethod`](https://github.com/rails/rails/issues/38998).

## Рецепт

Вот к чему я в итоге пришёл. Рецепт я запускал на 6.1.7.6, а заголовок и поведение форматов проверил на 8.1.4.

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

   ```ruby
   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`, а не со строкой):

   ```ruby
   match '/404', to: 'errors#not_found', via: :all
   ```

   ```ruby
   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:

   ```ruby
   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-эндпоинты оставались прежними, так что я от него отказался. Однострочная альтернатива такая:

   ```ruby
   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 по роутингу](https://guides.rubyonrails.org/routing.html): [Dynamic Segments](https://guides.rubyonrails.org/routing.html#dynamic-segments), [Format Segments](https://guides.rubyonrails.org/routing.html#format-segments)
- [Руководство Rails по конфигурации, `config.exceptions_app`](https://guides.rubyonrails.org/configuring.html#config-exceptions-app)
- API: [`ActionController::MimeResponds`](https://api.rubyonrails.org/classes/ActionController/MimeResponds.html), [`ActionDispatch::PublicExceptions`](https://api.rubyonrails.org/classes/ActionDispatch/PublicExceptions.html), [`ActionDispatch::ShowExceptions`](https://api.rubyonrails.org/classes/ActionDispatch/ShowExceptions.html)
- [`Rack::Cascade`](https://rubydoc.info/gems/rack/Rack/Cascade)
- [RFC 9110, HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html)
- Коммиты Rails: [7611fa6d46](https://github.com/rails/rails/commit/7611fa6d46) (`Request#format`, 2006), [11af089ce](https://github.com/rails/rails/commit/11af089ce) (`ShowExceptions`, 2009), [deef8dd68](https://github.com/rails/rails/commit/deef8dd68) и [535926269](https://github.com/rails/rails/commit/535926269) (exceptions app и `X-Cascade`, 2011)
- [Коммит Spree 704baa4](https://github.com/spree/spree/commit/704baa4) и [Solidus PR #2329](https://github.com/solidusio/solidus/pull/2329)
- [Твит, с которого всё началось](https://x.com/tot_ra/status/2116168495914426564)
