Timeouts

OxPHP enforces two independent timeouts that protect against slow clients and runaway requests. The header timeout guards the connection phase at the server level. PHP execution time is bounded by PHP's own max_execution_time ini directive (and the set_time_limit() runtime function). One property of that limit differs from PHP-FPM in a way that is easy to miss: OxPHP runs a thread-safe (ZTS) PHP build, where the limit measures wall-clock time rather than CPU time. See Wall-clock, not CPU time.

How it works

Each request passes through these phases:

  1. When the connection is accepted, the header timeout starts. OxPHP waits for the client to send a complete set of HTTP headers.
  2. When the headers are received, the header timeout ends and the request is dispatched to a PHP worker.
  3. PHP processes the request. Application code runs under PHP's own max_execution_time, armed as a per-thread POSIX timer. When the limit is reached, the request is cancelled and the unified Request cancelled (timeout) fatal fires. In worker mode each request keeps a limit of its own, including one the worker takes while it is carrying others, and a request suspended in a wait when its limit runs out is ended as it resumes (see Worker Mode → Request deadlines).
  4. The response is sent. On keep-alive connections, the cycle repeats from step 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

On keep-alive connections, both timeouts apply independently to each request in the connection.

Note

When TLS is enabled, the header timeout starts after the TLS handshake completes, not when the TCP connection is accepted.

The header timeout protects against slowloris-style attacks, where a client sends headers one byte at a time to hold connections open indefinitely.

PHP execution timing is delegated entirely to PHP. When max_execution_time is exceeded, OxPHP's unified cancellation path:

  • Sets connection_status() & PHP_CONNECTION_TIMEOUT so userland code can detect the cause.
  • Runs all register_shutdown_function() callbacks, exactly as PHP-FPM does.
  • Returns HTTP 504 Gateway Timeout with the message Request cancelled (timeout) written to the error log.

Wall-clock, not CPU time

max_execution_time measures how long the request has been alive, not how much CPU it has burned. Every second spent waiting counts: a slow database query, an HTTP call to another service, a blocking fread() on a socket, a sleep().

This differs from PHP-FPM, and the difference lies in the PHP build rather than in OxPHP. A thread-safe (ZTS) build, which OxPHP requires because it runs many PHP interpreters as threads of one process, arms the limit as a per-thread POSIX timer on a monotonic clock. A non-thread-safe build, which is what PHP-FPM runs, arms it with setitimer(ITIMER_PROF), a timer that only advances while the process is on CPU. That is where the familiar rule of thumb "waiting on the database doesn't count against max_execution_time" comes from. It holds for PHP-FPM and does not hold here.

The consequence shows up when migrating an existing application:

Request: 25s waiting on a report query, 2s rendering Charged against a 30s limit
PHP-FPM (non-thread-safe build) ~2s (the limit is effectively unreachable)
OxPHP (thread-safe build) 27s (a single slow query can exhaust the limit)
Migrating from PHP-FPM

An application that never once hit max_execution_time under PHP-FPM can start returning 504 on its slowest endpoints after moving to OxPHP, with no change to the directive's value. Size the limit against total request latency, including everything the request waits on, and raise it for endpoints that block on external systems.

Cancellation status codes

OxPHP cancels a request for several distinct reasons. Each maps to a wire status that reflects the actual condition rather than a generic 500:

Cause Status Notes
max_execution_time / set_time_limit() exceeded 504 Gateway Timeout Server-side execution-time exhaustion.
Server graceful shutdown drained the request 503 Service Unavailable Adds Retry-After: 5 so clients retry against a recovered or replacement instance.
Client closed the connection mid-request 499 nginx-style "Client Closed Request". The connection is already gone, so this status only ever appears in access logs and metrics and is never written to the wire. It surfaces client-driven aborts as non-5xx so they don't pollute server-error alerts.

These three are the only cancellation causes. A request the supervisor observes running past its threshold is reported through oxphp_worker_stuck_total and oxphp_worker_long_running_total and left running; the server never cancels it.

If your ERROR_PAGES_DIR only ships a 500.html, add 504.html, 503.html, and (optionally) 499.html to keep the styled pages consistent across cancellation causes.

Configuration

Variable Default Description
HEADER_TIMEOUT_SECONDS 5 Maximum seconds to receive request headers after the connection is accepted. Protects against slowloris attacks. 0 is not treated specially. It passes through to hyper as a zero-second timeout, which fires immediately. To disable the timeout, unset the variable rather than setting it to 0

PHP execution time is configured via php.ini, not OxPHP env vars:

php.ini
; php.ini max_execution_time = 30

Or per-script at runtime:

php
set_time_limit(60); // 60 seconds from now set_time_limit(0); // disable for this request
Scenario Header Timeout max_execution_time
API server 5s 30s
General web serving 5s 60s
File uploads 10s 300s
SSE / long-polling 5s 0 (disabled, set per-script)

Adjust these values based on your application's characteristics. For SSE endpoints, call set_time_limit(0) at the top of the streaming script rather than disabling max_execution_time globally.

Troubleshooting

Clients receive 504 with "Request cancelled (timeout)" unexpectedly

The PHP execution-time limit fired before the script finished.

If these requests were fine under PHP-FPM and started failing after the move to OxPHP, the cause is most likely the timer semantics described in Wall-clock, not CPU time: time the request spends waiting on a database or an upstream service counts against the limit.

Fix: Raise max_execution_time for the affected script, or call set_time_limit($seconds) to extend it at runtime:

php
// at the top of a slow script set_time_limit(300);

For SSE or streaming endpoints where connections must stay open indefinitely, disable the timer for that script:

php
set_time_limit(0);
Connections are dropped before headers arrive

The header timeout is too short for clients on high-latency links or behind slow load balancers.

Fix: Increase the header timeout:

bash
HEADER_TIMEOUT_SECONDS=15
OPcache causes the first request after a script change to timeout

OPcache recompilation adds latency on the first request after a file change. This is more common in development environments with many files. Raise max_execution_time or disable it during development.

Docker example

compose.yaml
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

With php.ini containing:

php.ini
max_execution_time = 30

Best practices

  • Never set max_execution_time = 0 globally in production unless you have SSE or long-polling endpoints that require indefinite connections. Prefer set_time_limit(0) per-script.
  • Use shorter limits for API servers. APIs have predictable response times. A 30-second max_execution_time catches stuck requests quickly without affecting normal traffic. Size it against your slowest downstream dependency rather than the CPU work the script does, because time spent waiting counts.
  • Combine with rate limiting. Timeouts protect individual requests; rate limiting protects against high request volume. Together they cover both slow and fast attack patterns.

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Set OxPHP's timeouts around my longest request. Read the `features/timeouts` page through the OxPHP documentation MCP server. Find what actually runs longest in this repository (uploads, reports, exports, and the waits on databases and upstream services, since the limit counts wall-clock time), then give me the header timeout and the `max_execution_time` to run with, plus the per-script overrides for anything that cannot live under a global limit.

See also

Found a mistake? Report it →