Перейти к основному содержимому
БЛОГ

RateLimit-Policy: пусть ИИ-агенты сами сбавляют темп

RateLimit-Policy (черновик IETF, ревизия 11) позволяет ИИ-агентам узнать вашу квоту и сбавить темп, не дожидаясь 429. Синтаксис, nginx, Nitro, FastAPI, curl.

Автор: IMozzОбновлено 2026-10-29
Лимиты запросов для ИИ-агентов: RateLimit-Policy, RateLimit, Retry-After — aiSiteReady

Заголовок RateLimit-Policy сообщает ИИ-агенту, сколько запросов ваш сервис принимает за временное окно, а парный ему RateLimit — сколько из них осталось прямо сейчас. Агент, который читает оба, сам держит темп, притормаживает до того, как квота закончится, и ждёт ровно столько секунд, сколько нужно, вместо того чтобы нащупывать ваш лимит через 429 Too Many Requests или блокировку по IP.

Лимиты запросов относятся к слою Правила для ботов в стеке готовности. robots.txt говорит, можно ли краулеру заходить; эти заголовки — как быстро ему можно двигаться. Важнее всего они для API, который вы открываете агентам (см. как сделать сайт доступным для вызова агентами и каталог API), но те же две строки на главной странице уже подсказывают краулеру, чего ожидать.

Оба поля описаны в IETF Internet-Draft, а не в RFC, поэтому я отмечаю то, что ещё может измениться. Общая картина — в материале о том, что значит готовность к ИИ-агентам.

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

  • Два поля ответа, оба — списки элементов Structured Field: RateLimit-Policy (q — квота, w — окно, необязательные qu — единица измерения и pk — ключ раздела) и RateLimit (r — остаток, t — эффективное окно в секундах) (IETF draft).
  • Статус на октябрь 2026: Internet-Draft, ревизия 11 от 23 мая 2026, номера RFC нет; синтаксис последний раз менялся в ревизии 08, но открытые pull request переименовали бы параметры r и t поля RateLimit в a и w, разрешили бы имена политик без кавычек и добавили бы поле RateLimit-Partition. Считайте примеры текущей формой, а не обещанием.
  • Клиента останавливает именно 429 с Retry-After (RFC 6585, RFC 9110); по черновику при наличии обоих приоритет у Retry-After.
  • Объявляйте постоянную политику на страницах, а живой остаток — в некэшируемых ответах API, и только для лимитов, которые действительно применяете. По состоянию на октябрь 2026 года фронтенд нашего сайта жёстко прописывает q=20;w=60, а бэкенд берёт лимит из конфига — риск рассинхронизации описан ниже.
  • Проверка rate_limit_hints в aiSiteReady засчитывает PASS за разбираемый RateLimit или RateLimit-Policy либо корректный Retry-After; устаревшие X-RateLimit-* и одинокий 429 дают только PARTIAL (подробности в конце).

Что на самом деле говорят RateLimit-Policy и RateLimit?

Это два поля ответа, которые делят одну задачу пополам: RateLimit-Policy описывает постоянное правило, а RateLimit сообщает текущее состояние по этому правилу. Оба — списки Structured Field (RFC 9651), элементы которых — строки в кавычках с именем политики, за которыми через ; идут параметры. Именно имя политики связывает два заголовка.

Вот рабочий пример с согласованными числами. Политика "api";q=100;w=60 разрешает 100 запросов за окно в 60 секунд. На 25-й секунде окна агент потратил 60 из них, поэтому сервер отвечает "api";r=40;t=35: не больше 40 запросов за следующие 35 секунд — столько осталось до конца окна.

Два заголовка: RateLimit-Policy (api, q=100, w=60) — правило; RateLimit (api, r=40, t=35) — текущее состояние: использовано 60 из 100, окно 35 с.

Параметров немного, и каждый определён в черновике (IETF draft):

ПолеПараметрОбязателенЗначение
RateLimit-PolicyqдаКвота, выделенная политикой, в единицах квоты
RateLimit-PolicywнетОкно в секундах, ненулевое целое
RateLimit-PolicyquнетЕдиница квоты; по умолчанию requests, также content-bytes и concurrent-requests
RateLimitrдаКвота, ещё доступная по названной политике
RateLimittнетСекунды, в течение которых клиент может использовать не больше r
обаpkнетКлюч раздела (byte sequence) для квот на пользователя или ресурс

Три правила черновика определяют реализацию. RateLimit-Policy должен оставаться неизменным от ответа к ответу, тогда как RateLimit может меняться в каждом. Клиенты вправе игнорировать детали политики, обязаны игнорировать некорректные поля и не должны считать, что следующие ответы вообще будут содержать эти заголовки. А положительный r — подсказка, а не гарантия: клиенты не должны воспринимать его как соглашение об уровне сервиса. В списке может быть несколько политик, например "burst";q=100;w=60,"daily";q=1000;w=86400.

Это уже стандарт и какие заголовки отдавать?

Пока нет: это Internet-Draft IETF. Отдавайте пару из ревизии 08 — RateLimit-Policy и RateLimit — плюс Retry-After в каждом 429. Синтаксис менялся уже несколько раз, последний — в ревизии 08. На октябрь 2026 последняя ревизия — draft-ietf-httpapi-ratelimit-headers-11 от 23 мая 2026. Она принадлежит рабочей группе IETF HTTPAPI, в шапке указан Standards Track, а datatracker не показывает номера RFC и сообщает срок действия до 24 ноября 2026 (datatracker).

Поддержка неравномерна, и об этом стоит сказать один раз прямо. REST API GitHub отдаёт заголовки x-ratelimit-*, где сброс — это метка UTC epoch (документация GitHub). Фильтр rate limit в Envoy умеет отдавать X-RateLimit-* по черновику draft-polli-03, а не под актуальными именами (документация Envoy). В FAQ краулера Anthropic сказано, что его боты учитывают Crawl-delay из robots.txt «где уместно» (Anthropic).

Сигнал, который понимает каждый клиент, — это 429 с Retry-After; Google, например, считает 429 признаком перегрузки сервера и замедляет обход (Google). Новые заголовки всё равно заслуживают двух строк: они почти ничего не стоят, а всё, что их читает, включая сканеры вроде aiSiteReady, видит ваши лимиты.

История черновика объясняет, почему старые руководства противоречат друг другу. Чтобы собрать эту таблицу, я сравнил архивные тексты ревизий:

Ревизия (дата)Как выглядели заголовки
Вендорная конвенция, не стандартизированаX-RateLimit-Limit, -Remaining, -Reset; сброс может быть в секундах, миллисекундах, epoch или датой
00–03 (дек. 2020 — март 2022)RateLimit-Limit: 100, RateLimit-Remaining: 50, RateLimit-Reset: 7
04–06 (май — дек. 2022)Те же три плюс RateLimit-Policy: 100;w=10
07 (июнь 2023)Поле-словарь RateLimit: limit=100, remaining=50, reset=5 рядом с RateLimit-Policy: 100;w=10
08–11 (окт. 2024 — май 2026)Списки элементов: RateLimit: "api";r=40;t=35 и RateLimit-Policy: "api";q=100;w=60

Что отдавать? Пару из ревизий 08 и новее: это актуальная форма и единственная с именованными политиками. Устаревшие X-RateLimit-* оставляйте, только если от них зависит известный вам клиент, и с теми же числами. Тройку RateLimit-Limit/-Remaining/-Reset из ревизий 00–06 и словарный синтаксис ревизии 07 не добавляйте.

Эндпоинт сканирования aiSiteReady (POST /api/v1/scans) при 429 отдаёт оба семейства: Retry-After, тройку X-RateLimit-* и современную пару. Это перестраховка, пока черновик не устоялся, а не образец на века.

Какое место занимают 429 и Retry-After?

Они — исполняющая половина; новые заголовки — прогноз. 429 Too Many Requests означает, что клиент отправил слишком много запросов за заданное время. Кэши не должны сохранять ответ 429, а сам ответ может содержать Retry-After (RFC 6585, раздел 4). Retry-After содержит либо число секунд, либо HTTP-дату (RFC 9110, раздел 10.2.3).

Черновик не привязан к коду статуса: RateLimit может появиться в любом ответе, а в собственном FAQ черновика сказано, что спецификация не связана с 429. Если в ответе есть и RateLimit, и Retry-After, клиент обязан следовать Retry-After, а сервер не должен ставить его раньше конца эффективного окна. Полный ответ с ограничением выглядит так:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 35
RateLimit-Policy: "api";q=100;w=60
RateLimit: "api";r=0;t=35

{
  "type": "https://iana.org/assignments/http-problem-types#quota-exceeded",
  "title": "Too Many Requests",
  "status": 429,
  "violated-policies": ["api"]
}

URI в type — это тип проблемы, который черновик предлагает зарегистрировать на основе RFC 9457; обычное JSON-тело ошибки тоже подойдёт. Основную работу делают заголовки, а Retry-After здесь намеренно равен t, чтобы два сигнала никогда не противоречили друг другу.

Как ИИ-агенту планировать запросы и притормаживать?

Агенту нужно держать темп по политике, делать паузу, когда r близок к нулю, соблюдать Retry-After при 429 и ограничивать любое ожидание. Во-первых, один раз прочитать RateLimit-Policy и держать темп: для "api";q=100;w=60 это один запрос раз в 0,6 секунды (60 делить на 100), и квота при этом не будет превышена.

Во-вторых, читать RateLimit после каждого ответа и, когда r близок к нулю, придержать следующий запрос на t секунд. Черновик допускает оба стиля: замедляться по мере приближения к лимиту или потратить всю квоту и затем ждать.

В-третьих, при 429 или 503 ждать Retry-After; если его нет — t секунд из RateLimit, а дальше экспоненциальная задержка с джиттером. В-четвёртых, ограничивать, сколько вы готовы ждать, и игнорировать мусор: раздел безопасности черновика предупреждает о значениях вроде Retry-After: 1000000 и советует клиентам задать собственные пороги.

Как агент сбавляет темп: 200 с r=1, t=35 — пауза 35 с; 429 с Retry-After: 35 — ждёт 35 с плюс джиттер и повторяет запрос.

Вот этот цикл (шаги со второго по четвёртый) для Node 18 и новее с глобальным fetch. В октябре 2026 года я запускал его на middleware FastAPI из примера ниже с маленькой квотой — 4 запроса за 8 секунд: он делал паузы между окнами и ни разу не получил 429, а когда я сначала исчерпал квоту через curl, дождался Retry-After.

const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000));

// Минимальный разбор типичной формы "api";r=40;t=35. Для всего более
// экзотического (ключи раздела и т. п.) берите библиотеку Structured Fields из RFC 9651.
function readRateLimit(value: string | null) {
  if (!value) return null;
  const items = value.split(/,(?=\s*")/).flatMap((member) => {
    const [name, ...params] = member.trim().split(";");
    const p = Object.fromEntries(params.map((s) => s.trim().split("=")));
    const r = Number(p.r);
    const t = p.t === undefined ? undefined : Number(p.t); // эффективное окно, секунды
    if (!/^".+"$/.test(name) || !Number.isInteger(r) || r < 0) return []; // некорректное: игнорируем
    if (t !== undefined && (!Number.isInteger(t) || t < 0)) return []; // то же для t
    return [{ policy: name.slice(1, -1), remaining: r, reset: t }];
  });
  // Если политик несколько, ориентируемся на ту, что ближе всего к исчерпанию.
  return items.sort((a, b) => a.remaining - b.remaining)[0] ?? null;
}

// Retry-After — это delay-seconds или HTTP-дата (RFC 9110, раздел 10.2.3).
function retryAfterSeconds(value: string | null): number | null {
  if (!value) return null;
  if (/^\d+$/.test(value.trim())) return Number(value);
  const at = /[a-z]/i.test(value) ? Date.parse(value) : NaN; // в HTTP-дате есть буквы
  return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - Date.now()) / 1000));
}

const MAX_WAIT = 600; // секунд; абсурдные ожидания не ждём, а сообщаем о них
let notBefore = 0; // epoch в мс: следующий запрос не раньше этого момента

export async function politeFetch(url: string, attempts = 4): Promise<Response> {
  for (let i = 0; ; i++) {
    const pause = notBefore - Date.now();
    if (pause > 0) await sleep(pause / 1000);

    const res = await fetch(url, {
      headers: { "User-Agent": "acme-agent/1.0 (+https://acme.example/bot)" },
    });
    const limit = readRateLimit(res.headers.get("ratelimit"));

    if (res.status === 429 || res.status === 503) {
      // Retry-After приоритетнее RateLimit, если есть оба.
      const wait = retryAfterSeconds(res.headers.get("retry-after")) ?? limit?.reset ?? 2 ** i;
      if (i + 1 >= attempts || wait > MAX_WAIT) return res; // сдаёмся, пусть вызывающий код увидит ответ
      notBefore = Date.now() + (wait + Math.random()) * 1000; // джиттер
      await res.body?.cancel(); // отбрасываем тело перед повтором
      continue;
    }

    // Квота почти кончилась: придерживаем следующий запрос на t секунд (эффективное окно).
    if (limit && limit.remaining <= 1 && limit.reset !== undefined && limit.reset <= MAX_WAIT) {
      notBefore = Date.now() + limit.reset * 1000;
    }
    return res;
  }
}

Цикл минимальный: один origin, один процесс. Настоящему агенту нужен бюджет на каждый origin, общий для всех воркеров, и информативный User-Agent.

Как отдавать заголовки из nginx, Nitro и FastAPI?

Публикуйте постоянную политику везде, куда агент попадает первым, а живой остаток — только там, где запросы считает лимитер. Статичный RateLimit-Policy подходит для кэшируемых страниц, потому что не меняется, а живой RateLimit — нет: черновик велит клиентам игнорировать оба поля в ответах из кэша (IETF draft, раздел 7.3). Потерять статичную политику при попадании в кэш почти не страшно, а закэшированный живой r был бы неверным.

aiSiteReady делит их именно так: HTML-страницы несут "scan";q=20;w=60, а эндпоинт сканирования добавляет живую пару. scan — наша метка для API сканирования; черновик не придаёт именам политик никакого смысла, кроме связи двух заголовков, так что краулер может применить q=20;w=60 и к самим страницам — такой темп нас устраивает.

nginx умеет применять лимит и объявлять политику, но не может сообщить остаток квоты. Модуль limit_req работает по схеме «дырявого ведра» и даёт только переменную $limit_req_status, поэтому вы отдаёте статичный RateLimit-Policy и Retry-After при отказах (nginx limit_req).

# контекст http {}
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
limit_req_status 429;                 # статус отказа по умолчанию — 503

map $status $retry_after {
    429     1;                        # один запрос в секунду: слот освободится за 1 с
    default "";                       # пустое значение — заголовок не отправляется
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location /api/ {
        limit_req zone=api burst=59 nodelay;   # 60 сразу, затем один в секунду

        add_header RateLimit-Policy '"api";q=60;w=60' always;
        add_header Retry-After $retry_after always;

        proxy_pass http://app_backend;
    }
}

Важны три детали. Без always директива add_header применяется только к фиксированному списку кодов успеха и редиректов, так что ваш 429 ничего не получит. По умолчанию директивы наследуются, только если на текущем уровне нет собственных add_header (в nginx 1.29.3 и новее их можно объединить директивой add_header_inherit merge;), поэтому одна лишняя строка в location отбрасывает все вышестоящие (модуль headers в nginx). А трюк с map работает, потому что nginx не отправляет заголовок с пустым значением, как показывает исходный код.

Сначала попробуйте limit_req_dry_run on; и логируйте $limit_req_status, чтобы увидеть, что было бы отклонено. Я проверил эту конфигурацию в nginx 1.31.1 (Docker): ответы 200 и 404 несли RateLimit-Policy, отклонённые запросы получали 429 с Retry-After: 1, а с burst=59 клиент мог отправить 60 запросов сразу до первого 429. Если burst меньше, объявляйте меньшую квоту, например q=10;w=10 для burst=10. Строке listen 443 ssl нужны ещё ваши директивы ssl_certificate, а proxy_pass — блок upstream app_backend.

Nitro (Nuxt 4) — способ, которым aiSiteReady объявляет политику на собственных страницах. Это сокращённая копия плагина, который работает на этом сайте; в настоящем плагине значение строится в чистой функции, которую можно тестировать в Vitest.

// server/plugins/rate-limit-policy.ts (Nitro 2)
export default defineNitroPlugin((nitroApp) => {
  const policyHeader = `"scan";q=20;w=60`;

  nitroApp.hooks.hook("beforeResponse", (event) => {
    if (event.node.res.headersSent) return; // тело уже отправлено потоком
    const contentType = getResponseHeader(event, "content-type");
    if (typeof contentType !== "string" || !contentType.includes("text/html")) return;
    setResponseHeader(event, "RateLimit-Policy", policyHeader);
  });
});

Фильтр по text/html сделан намеренно: у шрифта или JavaScript-чанка нет лимита, который стоило бы объявлять. Эти имена относятся к Nitro 2, на котором работает наш сайт; в текущей документации Nitro вместо них definePlugin и хук response, так что проверьте свою версию (Nitro 2, Nitro 3).

FastAPI — место для живой пары. Этот middleware ведёт фиксированное окно для каждого клиента и отдаёт оба заголовка в каждом ответе, включая 429. В октябре 2026 года я запускал его через uvicorn и проверял командами curl, приведёнными ниже.

import math
import time

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

POLICY, QUOTA, WINDOW = "api", 100, 60  # должно совпадать с тем, что реально применяет лимитер ниже
_windows: dict[str, tuple[float, int]] = {}  # клиент -> (начало окна, счётчик)


def hit(client: str) -> tuple[bool, int, int]:
    """Считает один запрос; возвращает (разрешён, остаток, секунд до сброса)."""
    now = time.monotonic()
    start, count = _windows.get(client, (now, 0))
    if now - start >= WINDOW:  # окно закончилось: начинаем новое
        start, count = now, 0
    count += 1
    _windows[client] = (start, count)
    reset = max(1, math.ceil(start + WINDOW - now))
    return count <= QUOTA, max(0, QUOTA - count), reset


@app.middleware("http")
async def rate_limit(request: Request, call_next):
    client = request.client.host if request.client else "unknown"
    allowed, remaining, reset = hit(client)
    headers = {
        "RateLimit-Policy": f'"{POLICY}";q={QUOTA};w={WINDOW}',
        "RateLimit": f'"{POLICY}";r={remaining};t={reset}',
    }
    if not allowed:
        headers["Retry-After"] = str(reset)
        return JSONResponse({"detail": "Too many requests"}, status_code=429, headers=headers)
    response = await call_next(request)
    response.headers.update(headers)
    return response

Форма @app.middleware("http") взята из документации FastAPI (FastAPI). В проде замените словарь общим хранилищем; лимитер aiSiteReady использует атомарные Redis INCR и EXPIRE, чтобы все реплики API считали одно и то же окно. За обратным прокси ключом должен быть настоящий адрес клиента, а не прокси. Из бэкенда aiSiteReady стоит перенять главное: оба заголовка строятся из решения лимитера, а не из констант:

headers = {
    "RateLimit-Policy": f'"scan";q={rule.limit};w={rule.window_seconds}',
    "RateLimit": f'"scan";r={decision.remaining};t={decision.retry_after_seconds}',
}

Какие ошибки ломают заголовки лимитов?

Чаще всего ломается число, переставшее соответствовать реальности, или заголовок, которого нет в ответе, прочитанном агентом.

ОшибкаЧем вредитКак исправить
Объявлена политика, которую вы не применяетеАгенты доверяют числам, которых сервер не придерживается, либо вы блокируете ниже обещанной квотыГенерируйте значения из конфигурации лимитера; объявляйте не больше того, что применяете
Несовпадение окнаw=60 для «дырявого ведра» или скользящего окна либо t, посчитанный по другому окнуВыводите t из реального времени сброса лимитера; если окно не фиксированное, объявляйте с запасом
Две копии чиселСтраница говорит одну квоту, API — другуюОдин источник истины или тест, который их сравнивает
Устаревшие и современные заголовки расходятсяX-RateLimit-Reset как epoch рядом с t в секундах либо шлюз и приложение добавляют разные наборыЗаголовки ведёт один слой; если оставляете оба набора — с одинаковыми числами
Нет заголовков в ответах с ошибкойadd_header без always пропускает 429, а 429 без Retry-After не даёт инструкцииИспользуйте always и проверяйте путь 429
Живой RateLimit в кэшируемых ответахКэшированная копия показывает устаревший r; клиентам велено её игнорировать, а 429 кэшировать нельзяСтатичная политика на кэшируемых страницах, живые значения — в некэшируемых ответах API
Невидимы для браузерных агентовКросс-доменные скрипты не читают заголовки вне списка CORSДобавьте заголовки в Access-Control-Expose-Headers

На 2 октября 2026 года последняя строка — пробел в API самого aiSiteReady. По умолчанию браузеры показывают кросс-доменным скриптам лишь семь заголовков ответа, и ни RateLimit, ни Retry-After среди них нет (MDN). CORS-конфигурация бэкенда открывает только Content-Disposition, так что скрипт с другого origin их не увидит. Серверному агенту это не мешает, браузерному — помешало бы.

Ошибка, с которой я живу, — вторая копия чисел. Когда я добавил заголовок в июне 2026 года, политика для наших HTML-страниц превратилась в две константы в общем модуле фронтенда:

/** Quota (`q`): max scan requests per window. Mirrors `rate_limit_scan_ip_requests`. */
export const SCAN_RATE_LIMIT_QUOTA = 20;
/** Window (`w`) in seconds. Mirrors `rate_limit_scan_window_seconds`. */
export const SCAN_RATE_LIMIT_WINDOW_SECONDS = 60;

Бэкенд же читает rate_limit_scan_ip_requests и rate_limit_scan_window_seconds из настроек: по умолчанию 20 и 60, и оператор может переопределить их через переменные окружения. Комментарий над константами требует держать их синхронными. Тест фронтенда сверяет заголовок с его же константами, а тесты бэкенда проверяют вывод "scan";q=20;w=60 для лимитов, заданных как 20 и 60, но теста, который сравнивал бы одну сторону с другой, я не нашёл.

Поднимите лимит бэкенда до 60 — и эндпоинт сканирования будет объявлять q=60, пока главная всё ещё говорит q=20. Опустите до 10 — и главная пообещает больше, чем даёт API. Лечится это единым источником истины или тестом, который проверяет обе стороны; на 2 октября 2026 года это комментарий и два совпадающих значения по умолчанию.

Как проверить заголовки через curl?

Запросите страницу или эндпоинт, к которым агент обратится первыми, прочитайте заголовки из настоящего GET, а затем превысьте квоту своего API. Проверяйте публичный edge-URL: шлюзы и CDN перед вашим origin могут добавлять или убирать заголовки. Берите -D - -o /dev/null, а не curl -I: запрос HEAD может вернуть 405 или пройти мимо вашего middleware.

# 1. Что агент видит на первой загруженной странице?
curl -sS -D - -o /dev/null https://example.com/ | grep -i '^ratelimit'

# 2. Уменьшается ли живой остаток в API?
for i in 1 2 3; do
  curl -sS -D - -o /dev/null https://api.example.com/items/1 | grep -i '^ratelimit:'
done

# 3. Выйти за квоту (только на своём staging-API) и прочитать 429
for i in $(seq 1 120); do
  curl -sS -o /dev/null -w '%{http_code}\n' https://staging.example.com/items/1
done | sort | uniq -c
curl -sS -D - -o /dev/null https://staging.example.com/items/1 | grep -iE '^(HTTP|retry-after|ratelimit)'

В октябре 2026 года на middleware FastAPI выше вторая команда выводила счётчик, уменьшавшийся на единицу при каждом вызове, а третья (запущенная, когда окно уже сбросилось) — 100 200 и 20 429, а затем заголовки ответа 429. Ваши числа будут другими: t округляется вверх и уменьшается по мере старения окна.

ratelimit: "api";r=97;t=60
ratelimit: "api";r=96;t=60
ratelimit: "api";r=95;t=60

HTTP/1.1 429 Too Many Requests
ratelimit-policy: "api";q=100;w=60
ratelimit: "api";r=0;t=60
retry-after: 60

Имена заголовков не зависят от регистра, так что ratelimit: и RateLimit: — один и тот же заголовок. Дешёвую часть стоит вынести в CI. Этот скрипт проверяет только RateLimit-Policy — одну из форм, которые принимает aiSiteReady, — и только в самом распространённом виде: имя политики в кавычках, сразу за ним целое q. Сканер мягче: проходят и любой порядок параметров, и разбираемый RateLimit, и корректный Retry-After.

#!/usr/bin/env bash
set -euo pipefail
URL="${1:?usage: check-ratelimit.sh https://example.com/}"
HEADERS="$(curl -sS -D - -o /dev/null "$URL" | tr -d '\r')"
grep -qiE '^ratelimit-policy: "[^"]+";q=[0-9]+' <<<"$HEADERS" || { echo 'FAIL: no parseable RateLimit-Policy'; exit 1; }
grep -qiE '^x-ratelimit-' <<<"$HEADERS" && echo 'WARN: legacy X-RateLimit-* present, keep the numbers identical'
echo OK

Как aiSiteReady проверяет заголовки лимитов?

Проверка rate_limit_hints в aiSiteReady читает заголовки одного URL, который вы сканируете (обычно это главная страница), и оценивает найденное. Она относится к категории Правила для ботов рядом с проверкой AI bot rules (она читает ваш robots.txt) и проверкой правил использования контента. Сервер она бережёт: переиспользует ответ, который скан уже получил, лишних запросов не шлёт и переходит к единственному запросу HEAD, только если этого ответа нет.

Это проверка низкой серьёзности, и она выполняется во всех готовых профилях сканирования, от Все проверки до Контентный сайт и API/приложение. Если API живёт на другом URL, просканируйте и его: скан главной видит только политику этой страницы.

Как она оценивает найденное — по документации и коду проверки:

РезультатЧто увидел сканер
PASSРазбираемый RateLimit или RateLimit-Policy (имя политики в кавычках плюс целое r либо q) или корректный Retry-After
PARTIALТолько 429, только устаревшие заголовки (семейство RateLimit-Limit, X-RateLimit-*), вендорный заголовок bot-policy или современный заголовок, который не разбирается
FAILНичего из перечисленного
UNKNOWNЦель ответила антибот-блокировкой (HTTP 403) либо резервный HEAD завершился таймаутом или ошибкой, так что надёжных данных нет

Исключение для 429 сделано намеренно. Большинство проверок считают и 403, и 429 признаком «цель нас заблокировала», но здесь сам 429 — сигнал лимита, поэтому 429 с корректным Retry-After всё равно проходит.

На странице методологии приведены веса категорий и множители серьёзности. Эта проверка имеет низкую серьёзность (×0,5) и делит категорию Правила для ботов (на странице методологии — «Управление ботами») с проверками AI bot rules и правил использования контента, поэтому FAIL стоит около 3 баллов из 100 (3,6 в профиле Контентный сайт), а PARTIAL — примерно вдвое меньше. Проверка хорошо сочетается с управлением ИИ-краулерами через robots.txt, которое решает, кому вообще можно обходить сайт; а для общей картины пройдите 30-минутный аудит готовности к ИИ-агентам.

Запустите бесплатное сканирование, чтобы увидеть, объявляет ли сканируемая страница разбираемый RateLimit-Policy, какие устаревшие заголовки она всё ещё отдаёт и какое исправление сделать первым — на английском, украинском или русском.

Если коротко: отдавайте RateLimit-Policy для постоянного правила, RateLimit для живого остатка и Retry-After в каждом 429, генерируйте все три из лимитера, который реально их применяет, и проверяйте путь 429 через curl. Черновик ещё может измениться, но эти строки позволят агенту притормозить самому, не дожидаясь, пока это придётся делать вам.

У IMozz 20 лет в разработке ПО, а последний год он строит продукты с помощью LLM. Он развивает aiSiteReady, сканер только для чтения, который проверяет, может ли ИИ-агент прочитать и вызвать сайт. Сканер серверно рендерит собственный контент как рабочий пример.

Частые вопросы

Что такое заголовок RateLimit-Policy?
RateLimit-Policy — заголовок ответа из черновика рабочей группы IETF HTTPAPI, который объявляет постоянную квоту: имя политики в кавычках, q — квота и необязательный w — окно в секундах, например "api";q=100;w=60. Значение должно оставаться одинаковым во всех ответах. Парный заголовок RateLimit несёт текущий остаток (r) и эффективное окно (t) — число секунд, в течение которых клиент может израсходовать не больше r. Пока это Internet-Draft, а не RFC.
Что использовать: RateLimit или X-RateLimit?
Для нового кода отдавайте RateLimit и RateLimit-Policy из черновика. X-RateLimit-* оставьте, только если от них зависит известный вам клиент, и следите, чтобы два набора не противоречили друг другу. Заголовки с X- никогда не стандартизировали: у GitHub сброс — это метка времени Unix (epoch, UTC), а t в черновике — число секунд от текущего момента. В любом случае 429 должен нести Retry-After.
Что должен делать ИИ-агент, получив 429?
Прекратить слать запросы на этот origin и подождать. Если есть Retry-After, соблюдайте его: это число секунд или HTTP-дата, и по черновику он приоритетнее RateLimit. Без него ждите t секунд из RateLimit, затем наращивайте паузу экспоненциально с джиттером. Ограничьте ожидание, сдавайтесь после нескольких попыток и сообщайте об ошибке, а не повторяйте бесконечно.
RateLimit уже стал RFC?
Нет. На октябрь 2026 последняя ревизия — draft-ietf-httpapi-ratelimit-headers-11 от 23 мая 2026: Internet-Draft рабочей группы IETF HTTPAPI, срок которого истекает 24 ноября 2026, если его не продлят. Синтаксис менялся уже несколько раз, последний — между ревизиями 07 и 08, поэтому перед копированием примеров из любой статьи, включая эту, загляните в datatracker.