12k
All articles

У HTTP появился новый метод для поиска

Разберитесь в HTTP QUERY — безопасном идемпотентном методе поиска с телом запроса. Узнайте о кэшировании, повторах, поддержке клиентов и возможных блокировках.

OpenReplay Team
OpenReplay Team
У HTTP появился новый метод для поиска

Метод HTTP QUERY, определённый в RFC 10008 в июне 2026 года, передаёт запрос в теле, как POST, но при этом безопасен и идемпотентен, как GET. Поэтому ответы на него можно кэшировать, а неудавшийся запрос можно автоматически повторить.

Большинство API-команд сталкивались с поисковым эндпоинтом, который начинается как аккуратный GET, а заканчивается URL-адресом с URL-кодированными вложенными фильтрами, диапазонами дат и ключами сортировки. Затем кто-то переводит его на POST, и кэширование с повторными попытками перестают работать.

В статье разбирается, какую проблему решает QUERY, как выглядит обмен QUERY-сообщениями на уровне протокола и какие компоненты стека уже поддерживают этот метод, а какие по-прежнему его отклоняют.

Ключевые выводы

  • QUERY — безопасный и идемпотентный метод HTTP, который передаёт запрос в теле. Он стандартизирован в RFC 10008 (июнь 2026 года).
  • Сервер обязан отклонить QUERY-запрос, если заголовок Content-Type отсутствует или не соответствует телу. Кэши, сохраняющие ответы на QUERY, включают тело запроса в ключ кэша.
  • Скрипты могут отправлять QUERY через fetch(), но HTML-форма с method="query" откатывается к GET и отбрасывает тело.
  • Кросс-доменный QUERY-запрос из браузера всегда вызывает предварительный CORS-запрос (preflight), поскольку QUERY не входит в список CORS-safelisted методов.
  • Любой сервер, прокси или WAF, который не распознаёт этот метод, может отклонить запрос до того, как он попадёт в ваше приложение.

Почему поисковые эндпоинты застревают между GET и POST?

Для сложного поиска плохо подходят и GET, и POST. GET помещает все фильтры в URL. POST переносит фильтры в тело, но сообщает всем промежуточным узлам, что запрос может изменить состояние.

RFC 9110 требует, чтобы любой отправитель и получатель поддерживал URI длиной не менее 8000 октетов. Это минимум, а не максимум, и HTTP не задаёт верхней границы, поэтому любой прокси, шлюз или сервер на пути может отклонить более длинный URL. Кроме того, длинные строки запроса оседают в истории браузера, серверных логах и закладках, что приводит к утечке всего, что содержат фильтры.

POST лишён этих проблем, но он не является ни безопасным, ни идемпотентным. Идемпотентный запрос имеет один и тот же предполагаемый эффект независимо от того, отправлен он один раз или десять. RFC 9110 предписывает клиентам не повторять неидемпотентный запрос автоматически, если у них нет способа убедиться, что на практике он идемпотентен. Поэтому кэши, как правило, не используют повторно ответы на POST, а шлюзы не повторяют POST-запрос, который завершился сбоем на полпути.

Что такое метод HTTP QUERY?

Согласно RFC 10008, QUERY-запрос просит сервер выполнить запрос, описанный в теле, и вернуть результат, ничего не изменяя на сервере. Благодаря этому клиент или прокси может повторно отправить или перезапустить QUERY, не опасаясь, что незавершённая первая попытка изменила состояние, — для POST такое допущение небезопасно. Вот как выглядит запрос на уровне протокола (из чего он состоит, описано в статье об анатомии HTTP-запроса):

QUERY /products/search HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json

{"category": "laptops", "price": {"max": 1500}, "brands": ["acme", "globex"], "sort": "price_asc"}

Запрос определяется телом вместе с его медиатипом. Если заголовок Content-Type отсутствует или не соответствует содержимому тела, RFC 10008 требует, чтобы сервер отклонил запрос. Рекомендации RFC 10008 по кодам состояния указывают на код 4xx, например 400, если медиатип не указан, и 415, если ресурс не поддерживает переданный медиатип.

Ответ может выглядеть так:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60
Accept-Query: application/json
Content-Location: /products/search/results/7f3a
Location: /products/search/queries/7f3a

{"results": [ ... ]}

Каждый заголовок выполняет свою задачу:

  • Ключ кэша: ответы на QUERY кэшируемы. Кэш, который их сохраняет, включает тело запроса в ключ кэша, поскольку два QUERY-запроса к одному и тому же URL с разными телами — это разные вопросы.
  • Accept-Query: сервер отправляет этот заголовок ответа, чтобы перечислить поддерживаемые форматы запросов в виде списка медиатипов в формате Structured Field. Одно значение распространяется на все URI сервера с тем же путём независимо от строки запроса.
  • Content-Location и Location: в ответе на QUERY заголовок Content-Location идентифицирует результат конкретного запроса. Location содержит URI самого запроса, который клиент может позже получить обычным GET. О том, как эти заголовки ведут себя в обычных условиях, рассказывается в статье о содержимом HTTP-ответа.

Cache-Control в примере — это обычная настройка кэширования. RFC 10008 её не требует.

Где QUERY уже работает?

Метод HTTP QUERY уже поддерживается в ряде серверных сред выполнения, фреймворков и HTTP-клиентов — при условии, что ничто между клиентом и сервером его не отклоняет.

  • Node.js принимает QUERY в модуле http. Встроенный парсер llhttp определяет HTTP_QUERY (см. копию llhttp.h в Node) и распознаёт этот метод начиная с llhttp 9.2. Согласно журналу изменений Node.js, эта версия парсера вошла в v21.7.2, а также включена в v22 и более поздние версии и в v20.19.2. В этих версиях http.METHODS содержит 'QUERY', а обработчик на сервере получает req.method === 'QUERY'.
  • HTTP-клиент Go принимает любой допустимый токен метода в качестве строки метода в http.NewRequest, поэтому уже умеет отправлять QUERY:
package main

import (
	"log"
	"net/http"
	"strings"
)

func main() {
	body := strings.NewReader(`{"category":"laptops","price":{"max":1500}}`)
	req, err := http.NewRequest("QUERY", "https://api.example.com/products/search", body)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
}
  • Laravel: в Laravel 13.19.0 добавлены клиентский метод Http::query() и тестовые хелперы query()/queryJson() для метода QUERY. Маршруты могут принимать QUERY через Route::match(); тестовые хелперы добавлены в laravel/framework PR #60662. Отдельный хелпер Route::query() влит в ветку master Laravel, а не в 13.x, поэтому ни один релиз 13.x его не содержит.

В браузере fetch() может отправлять QUERY из скрипта в пределах того же источника (same origin). В issue Fetch, посвящённом QUERY (whatwg/fetch#1938), отмечается, что стандарт Fetch не запрещает QUERY, но и не нормализует его регистр. Браузеры отправляют строку метода ровно так, как вы её написали, поэтому всегда используйте верхний регистр:

const res = await fetch('/products/search', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ category: 'laptops', price: { max: 1500 } }),
});
const data = await res.json();

Что пока не поддерживает QUERY?

Сегодня использованию метода HTTP QUERY мешают три вещи: HTML-формы не умеют его отправлять, некоторые серверы и средства защиты отклоняют метод сразу, а кросс-доменные вызовы требуют предварительного запроса.

Формы. Скрипты могут отправлять QUERY через fetch(), а формы — нет. Предложение о поддержке method="query" оформлено как WHATWG HTML issue #12594. Оно по-прежнему открыто и помечено как «needs implementer interest» («требуется интерес со стороны разработчиков браузеров»). Пока оно не принято, такая разметка:

<form method="query" action="/products/search">
  <input name="category" value="laptops">
  <button>Search</button>
</form>

приходит на сервер как GET без тела, потому что браузеры обрабатывают неизвестный метод формы как GET. Это демонстрирует живое воспроизведение, подготовленное для этого issue.

Серверы, шлюзы и WAF. Любой промежуточный узел, не распознающий метод, может его отклонить. В том же воспроизведении отмечено, что LiteSpeed, например, отвечает на QUERY кодом 400 ещё до запуска приложения. Обратные прокси, балансировщики нагрузки, CDN, API-шлюзы и файрволы нужно проверять по отдельности.

Кросс-доменный preflight. QUERY не входит в список CORS-safelisted методов стандарта Fetch. Поэтому кросс-доменный QUERY всегда вызывает предварительный запрос, какие бы заголовки он ни содержал:

OPTIONS /products/search HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: QUERY
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, QUERY
Access-Control-Allow-Headers: content-type

Если Access-Control-Allow-Methods не содержит QUERY, браузер блокирует основной запрос.

СредаСтатус
fetch(), тот же источникРаботает
fetch(), другой источникРаботает после preflight-запроса, разрешающего QUERY
HTML-формыОткат к GET, тело отбрасывается
Node.js httpРаботает (v20.19.2+, v21.7.2+, v22+; llhttp 9.2 распознаёт QUERY)
HTTP-клиент GoРаботает (пользовательская строка метода)
Laravel 13.19+Работает (клиент, тесты, Route::match)
LiteSpeedОтклоняет с кодом 400 до запуска приложения (отмечено в воспроизведении к #12594)
Прокси, CDN, шлюзы, WAFЗависит от продукта и его конфигурации

Стоит ли использовать QUERY в GraphQL?

QUERY хорошо подходит для query-операций GraphQL: они только читают данные и вписываются в модель безопасного, идемпотентного метода с телом. Мутации изменяют состояние, поэтому для них по-прежнему следует использовать POST. Если вы выбираете между двумя стилями API, компромиссы разобраны в статье GraphQL vs REST. Аргумент в пользу QUERY в обоих случаях один и тот же: поисковые операции чтения снова получают кэширование и повторные попытки на уровне HTTP.

Что должно произойти, чтобы QUERY заработал на всём пути

Чтобы QUERY работал повсеместно, его должен распознавать каждый уровень между пользователем и вашим обработчиком:

  1. Спецификация HTML должна принять method="query", а браузеры — реализовать эту возможность.
  2. Каждый промежуточный узел (сервер-источник, обратный прокси, балансировщик нагрузки, CDN, API-шлюз и WAF) должен разбирать и пропускать этот метод.
  3. Фреймворки должны маршрутизировать QUERY к обработчикам и разбирать его тело.
  4. Кэши должны включать тело запроса и Content-Type в ключ кэша, прежде чем сохранять ответы на QUERY.

Предварительный CORS-запрос предусмотрен намеренно: RFC 10008 его ожидает, а whatwg/fetch#1938 не предлагает менять список safelisted-методов. Открытым там остаётся вопрос о том, должен ли HTTP-кэш Fetch, ключом в котором служит URL, поддерживать кэширование QUERY с учётом тела запроса.

Заключение

QUERY устраняет давнее несоответствие в HTTP: поисковые запросы могут передавать тело, не жертвуя кэшированием и безопасными повторными попытками. Скрипты, клиенты на Go и приложения на Laravel могут использовать его уже сейчас. Формы — пока нет, а инфраструктура, не распознающая этот метод, по-прежнему может его отклонять. Прежде чем переводить эндпоинт на QUERY, отправьте реальный QUERY-запрос через весь продакшен-путь, включая CDN, шлюз и WAF, и сохраняйте POST-эндпоинт работающим, пока каждый промежуточный узел не начнёт пропускать новый метод.

Часто задаваемые вопросы

Почему бы просто не отправлять тело запроса с GET вместо использования QUERY?

Тело в GET-запросе ненадёжно. Согласно разделу 9.3.1 RFC 9110, тело GET-запроса не имеет общепринятой семантики и не может изменить цель или смысл запроса. Некоторые серверы сразу отклоняют такие запросы, поскольку тело GET может использоваться для атак типа request smuggling. Кроме того, тело не входит в стандартный ключ кэша. QUERY делает тело самим запросом и включает его в ключ кэша.

Чем метод QUERY отличается от метода SEARCH из WebDAV?

SEARCH — метод WebDAV, определённый в RFC 5323 в 2008 году, для поиска по DAV-ресурсам с помощью XML-грамматик запросов. QUERY — метод HTTP общего назначения для любого формата запросов. Оба метода безопасны, но SEARCH так и не вышел за пределы WebDAV. В ранних черновиках спецификации QUERY использовалось название SEARCH. Авторы перешли на QUERY, поскольку SEARCH и другие существующие безопасные методы происходят из WebDAV и опираются на обобщённый медиатип XML, а также потому, что название QUERY соответствует компоненту query в URI.

Можно ли документировать QUERY-эндпоинты в OpenAPI?

Да, начиная с OpenAPI 3.2.0, выпущенной в сентябре 2025 года. В этой версии появилась встроенная поддержка метода query, поэтому операция QUERY может объявлять схему requestBody и ответы так же, как get или post. Прочие нестандартные методы описываются в новом отображении additionalOperations. В OpenAPI 3.0 и 3.1 поля для QUERY нет, а поддержка 3.2 в инструментах различается от генератора к генератору.

Какой код состояния возвращает сервер, если он не поддерживает QUERY?

Согласно RFC 9110, сервер, который не распознаёт метод запроса, должен отвечать кодом 501 Not Implemented. Сервер, который распознаёт QUERY, но не разрешает его для конкретного ресурса, возвращает 405 Method Not Allowed с заголовком Allow, перечисляющим поддерживаемые методы. Прокси и WAF могут вести себя иначе, например возвращать обычный 400, поэтому логика отката к POST должна обрабатывать все три случая.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.