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), exactly as on any other SAPI.
How it works
Each request passes through these phases:
- Connection accepted — the header timeout starts. OxPHP waits for the client to send a complete set of HTTP headers.
- Headers received — the header timeout ends. The request is dispatched to a PHP worker.
- PHP processes the request — application code runs under PHP's own
max_execution_time(SIGALRM-driven). When the limit is reached, the request is cancelled and the unifiedRequest cancelled (timeout)fatal fires. - Response 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.
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 — it is never written to the wire. Surfaces client-driven aborts as non-5xx so they don't pollute server-error alerts. |
| Worker pronounced stuck by supervisor | 500 Internal Server Error |
Generic server error — cause (deadlock, blocked syscall, …) is unknown. |
| Userland-initiated cancellation | 500 Internal Server Error |
Userland may set its own status with http_response_code() before triggering the cancel; that explicit status is preserved. |
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.
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.10.0
ports:
- "8080:8080"
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. - Combine with rate limiting. Timeouts protect individual requests; rate limiting protects against high request volume. Together they cover both slow and fast attack patterns.
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