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:
- When the connection is accepted, the header timeout starts. OxPHP waits for the client to send a complete set of HTTP headers.
- When the headers are received, the header timeout ends and the request is dispatched to a PHP worker.
- 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 unifiedRequest 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). - 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.
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_TIMEOUTso userland code can detect the cause. - Runs all
register_shutdown_function()callbacks, exactly as PHP-FPM does. - Returns HTTP
504 Gateway Timeoutwith the messageRequest 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) |
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
max_execution_time = 30Or per-script at runtime:
set_time_limit(60); // 60 seconds from now
set_time_limit(0); // disable for this requestRecommended values
| 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:
// 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:
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:
HEADER_TIMEOUT_SECONDS=15OPcache 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
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:roWith php.ini containing:
max_execution_time = 30Best practices
- Never set
max_execution_time = 0globally in production unless you have SSE or long-polling endpoints that require indefinite connections. Preferset_time_limit(0)per-script. - Use shorter limits for API servers. APIs have predictable response times. A 30-second
max_execution_timecatches 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.
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
- Rate Limiting: per-IP request rate limiting
- Server-Sent Events (SSE): guidance on disabling timeouts for streaming endpoints
- Configuration Reference: complete environment variable reference