Таймауты
OxPHP применяет два независимых таймаута, которые защищают от медленных клиентов и зависших запросов. Таймаут заголовков ограничивает фазу установления соединения на уровне сервера. Время выполнения PHP ограничивается собственной ini-директивой PHP max_execution_time (и функцией времени выполнения set_time_limit()). Одно свойство этого лимита отличается от PHP-FPM, и его легко упустить: OxPHP работает на потокобезопасной (ZTS) сборке PHP, где лимит измеряет реальное (wall-clock) время, а не процессорное. См. Реальное время, а не процессорное.
Как это работает
Каждый запрос проходит через следующие фазы:
- Соединение принято — запускается таймаут заголовков. OxPHP ждёт, пока клиент пришлёт полный набор HTTP-заголовков.
- Заголовки получены — таймаут заголовков завершается. Запрос передаётся PHP-воркеру.
- PHP обрабатывает запрос — код приложения выполняется под собственным
max_execution_timePHP, который взводится как POSIX-таймер отдельного потока. Когда лимит достигнут, запрос отменяется и срабатывает унифицированная фатальная ошибкаRequest cancelled (timeout). В режиме воркеров у каждого запроса свой собственный лимит, в том числе у запроса, который воркер берёт, уже ведя другие, а запрос, приостановленный в ожидании в момент истечения своего лимита, завершается при возобновлении (см. Режим воркеров → Дедлайны запросов). - Ответ отправлен — на keep-alive-соединениях цикл повторяется с шага 1.
graph TD A["TCP connect<br/>(+ TLS handshake if enabled)"] -->|HEADER_TIMEOUT_SECONDS| B["Headers received"] B -->|max_execution_time| C["Response sent"] C -->|next request, keep-alive| A
На keep-alive-соединениях оба таймаута применяются независимо к каждому запросу в рамках соединения.
Когда включён TLS, таймаут заголовков запускается после завершения TLS-рукопожатия, а не в момент принятия TCP-соединения.
Таймаут заголовков защищает от атак типа slowloris, когда клиент отправляет заголовки по одному байту за раз, чтобы удерживать соединения открытыми неограниченно долго.
Отсчёт времени выполнения PHP полностью делегируется PHP. Когда max_execution_time превышен, унифицированный путь отмены в OxPHP:
- Устанавливает
connection_status() & PHP_CONNECTION_TIMEOUT, чтобы код в userland мог определить причину. - Выполняет все колбэки
register_shutdown_function(), ровно так же, как это делает PHP-FPM. - Возвращает HTTP
504 Gateway Timeout, записывая сообщениеRequest cancelled (timeout)в лог ошибок.
Реальное время, а не процессорное
max_execution_time измеряет, сколько времени запрос уже живёт, а не сколько процессорного времени он израсходовал. Учитывается каждая секунда ожидания: медленный запрос к базе данных, HTTP-вызов другого сервиса, блокирующий fread() на сокете, sleep().
В этом отличие от PHP-FPM, и дело здесь в сборке PHP, а не в OxPHP. Потокобезопасная (ZTS) сборка, которая нужна OxPHP, потому что он запускает множество интерпретаторов PHP как потоки одного процесса, взводит лимит как POSIX-таймер отдельного потока на монотонных часах. Непотокобезопасная сборка, на которой работает PHP-FPM, взводит его через setitimer(ITIMER_PROF) — таймер, который идёт только пока процесс занимает процессор. Отсюда и знакомое эмпирическое правило «ожидание базы данных не засчитывается в max_execution_time». Для PHP-FPM оно верно, а здесь — нет.
Последствия проявляются при миграции существующего приложения:
| Запрос: 25 с ожидания отчётного запроса, 2 с рендеринга | Засчитывается в лимит 30 с |
|---|---|
| PHP-FPM (непотокобезопасная сборка) | ~2 с (лимит фактически недостижим) |
| OxPHP (потокобезопасная сборка) | 27 с (один медленный запрос может исчерпать лимит) |
Приложение, которое под PHP-FPM ни разу не упиралось в max_execution_time, после переезда на OxPHP может начать возвращать 504 на самых медленных эндпоинтах — без всякого изменения значения директивы. Подбирайте лимит по полной задержке запроса, включая всё, чего запрос ждёт, и повышайте его для эндпоинтов, которые блокируются на внешних системах.
Статус-коды отмены
OxPHP отменяет запрос по нескольким разным причинам. Каждая из них отображается в статус-код на уровне протокола, который отражает фактическое состояние, а не обобщённый 500:
| Причина | Статус | Примечания |
|---|---|---|
Превышен max_execution_time / set_time_limit() |
504 Gateway Timeout |
Исчерпание времени выполнения на стороне сервера. |
| Корректное завершение работы сервера слило запрос | 503 Service Unavailable |
Добавляет Retry-After: 5, чтобы клиенты повторили запрос к восстановленному или заменяющему экземпляру. |
| Клиент закрыл соединение в середине запроса | 499 |
«Client Closed Request» в стиле nginx. Соединение уже разорвано, поэтому этот статус появляется только в логах доступа и метриках — на провод он никогда не записывается. Отражает инициированные клиентом обрывы как не-5xx, чтобы они не засоряли алерты о серверных ошибках. |
Других причин отмены нет, только эти три. О запросе, который, по наблюдению супервизора, выполняется дольше порога, сообщают метрики oxphp_worker_stuck_total и oxphp_worker_long_running_total, и он продолжает выполняться; сервер никогда его не отменяет.
Если ваш ERROR_PAGES_DIR содержит только 500.html, добавьте 504.html, 503.html и (опционально) 499.html, чтобы стилизованные страницы оставались единообразными для всех причин отмены.
Конфигурация
| Переменная | По умолчанию | Описание |
|---|---|---|
HEADER_TIMEOUT_SECONDS |
5 |
Максимальное число секунд на получение заголовков запроса после принятия соединения. Защищает от атак slowloris. 0 не обрабатывается особым образом — он передаётся в hyper как нулевой таймаут, который срабатывает немедленно. Чтобы отключить таймаут, снимите переменную, а не устанавливайте её в 0 |
Время выполнения PHP настраивается через php.ini, а не через env-переменные OxPHP:
; php.ini
max_execution_time = 30Или для отдельного скрипта во время выполнения:
set_time_limit(60); // 60 seconds from now
set_time_limit(0); // disable for this requestРекомендуемые значения
| Сценарий | Таймаут заголовков | max_execution_time |
|---|---|---|
| API-сервер | 5s | 30s |
| Общая веб-раздача | 5s | 60s |
| Загрузка файлов | 10s | 300s |
| SSE / long-polling | 5s | 0 (отключено, задавать для каждого скрипта) |
Подбирайте эти значения исходя из характеристик вашего приложения. Для SSE-эндпоинтов вызывайте set_time_limit(0) в начале потокового скрипта, а не отключайте max_execution_time глобально.
Устранение неполадок
Клиенты неожиданно получают 504 с «Request cancelled (timeout)»
Лимит времени выполнения PHP сработал раньше, чем скрипт завершился.
Если под PHP-FPM эти запросы работали нормально, а сбоить начали после переезда на OxPHP, причина, скорее всего, в семантике таймера, описанной в разделе Реальное время, а не процессорное: время, которое запрос проводит в ожидании базы данных или вышестоящего сервиса, засчитывается в лимит.
Решение: Повысьте max_execution_time для затронутого скрипта или вызовите set_time_limit($seconds), чтобы продлить его во время выполнения:
// at the top of a slow script
set_time_limit(300);Для SSE- или потоковых эндпоинтов, где соединения должны оставаться открытыми неограниченно долго, отключите таймер для этого скрипта:
set_time_limit(0);Соединения обрываются до прихода заголовков
Таймаут заголовков слишком короток для клиентов на каналах с высокой задержкой или за медленными балансировщиками нагрузки.
Решение: Увеличьте таймаут заголовков:
HEADER_TIMEOUT_SECONDS=15OPcache приводит к таймауту первого запроса после изменения скрипта
Перекомпиляция OPcache добавляет задержку на первом запросе после изменения файла. Это чаще встречается в средах разработки с большим числом файлов. Повысьте max_execution_time или отключите его на время разработки.
Пример для Docker
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
environment:
HEADER_TIMEOUT_SECONDS: "5"
volumes:
- ./app:/var/www/html:ro
- ./php.ini:/usr/local/etc/php/conf.d/zz-app.ini:roС php.ini, содержащим:
max_execution_time = 30Рекомендуемые практики
- Никогда не устанавливайте
max_execution_time = 0глобально в продакшене, если только у вас нет SSE- или long-polling-эндпоинтов, которым требуются неограниченные по времени соединения. Предпочитайтеset_time_limit(0)для каждого скрипта отдельно. - Используйте более короткие лимиты для API-серверов. У API предсказуемое время ответа. 30-секундный
max_execution_timeбыстро отлавливает застрявшие запросы, не влияя на обычный трафик. Подбирайте его по самой медленной внешней зависимости, а не по объёму вычислений в скрипте, потому что время ожидания тоже учитывается. - Сочетайте с ограничением частоты запросов. Таймауты защищают отдельные запросы; ограничение частоты запросов защищает от большого объёма запросов. Вместе они покрывают как медленные, так и быстрые шаблоны атак.
Промпты для вашего агента
Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.
Выстави таймауты OxPHP вокруг моего самого долгого запроса. Прочитай страницу `features/timeouts` через MCP-сервер документации OxPHP.
Найди, что на самом деле выполняется дольше всего в этом репозитории (загрузки файлов, отчёты, выгрузки, а также ожидание баз данных и вышестоящих сервисов, ведь лимит считает реальное время), и дай таймаут заголовков и `max_execution_time`, с которыми работать, плюс переопределения на уровне отдельных скриптов для всего, что не умещается в общий лимит.Смотрите также
- Ограничение частоты запросов — ограничение частоты запросов по IP
- Server-Sent Events (SSE) — руководство по отключению таймаутов для потоковых эндпоинтов
- Справочник по конфигурации — полный справочник переменных окружения