Building a FastCGI Client from Scratch in Go
The FastCGI protocol is from 1996 but still powers most PHP applications. Here is what I learned implementing a client library with context support and proper error handling.
When I started building FPM Exporter, I needed a Go library that could talk FastCGI to PHP-FPM sockets. The protocol itself dates back to 1996, and there are several Go implementations available. I tried three of them before deciding to write my own. Here is why, and what I learned about the protocol along the way.
The FastCGI Protocol
FastCGI is a binary protocol for communicating between a web server and an application server. Unlike CGI, which spawns a new process for every request, FastCGI keeps the application server running and multiplexes requests over persistent connections.
Every FastCGI message is a record with an 8-byte header:
Version (1 byte) | Type (1 byte) | Request ID (2 bytes)
Content Length (2 bytes) | Padding Length (1 byte) | Reserved (1 byte)
[Content Data ...] [Padding ...]
The record types that matter for a client:
Type | Name | Purpose |
|---|---|---|
1 |
| Start a new request |
3 |
| Server signals request complete |
4 |
| Key-value pairs (like HTTP headers) |
5 |
| Request body data |
6 |
| Response body data |
7 |
| Error output |
A minimal request flow looks like this:
Client sends
FCGI_BEGIN_REQUESTwith a role (usuallyRESPONDER) and flagsClient sends
FCGI_PARAMSrecords with key-value pairs encoding the CGI environment (SCRIPT_FILENAME,REQUEST_METHOD,QUERY_STRING, etc.)Client sends an empty
FCGI_PARAMSrecord to signal "no more params"Client sends
FCGI_STDINwith the request body (if any), terminated by an empty recordServer sends
FCGI_STDOUTrecords with the response (HTTP headers + body)Server optionally sends
FCGI_STDERRrecords with error messagesServer sends
FCGI_END_REQUESTwith an application status and protocol status
The key-value encoding in FCGI_PARAMS is its own mini-format. Each key and value is prefixed with a length, where lengths under 128 use one byte and lengths 128 or above use four bytes with the high bit set.
Why Existing Libraries Did Not Work
The three Go FastCGI libraries I evaluated all had the same problems:
No context support. Go's context.Context is how you propagate cancellation and timeouts through a call chain. None of the libraries accepted a context, which meant I couldn't set a timeout on a FastCGI request or cancel it when a parent context was done. In a metrics collector that polls PHP-FPM every 30 seconds, a hung connection without a timeout is a resource leak that silently breaks your monitoring.
Poor error handling. The libraries returned generic errors or panicked on malformed responses. When PHP-FPM sends an unexpected record type (which happens more often than you would think, especially during FPM restarts), the client should return a typed error, not crash.
No connection pooling awareness. FastCGI supports multiplexing multiple requests over a single connection using request IDs. None of the libraries I found handled this correctly. They either used a new connection per request (slow) or assumed exclusive connection access (unsafe for concurrent use).
Designing fcgx
The library I built, fcgx, makes three design choices:
Context-first API. Every method takes a context.Context as its first argument:
resp, err := client.Execute(ctx, fcgx.Request{
Role: fcgx.RoleResponder,
Params: map[string]string{
"SCRIPT_FILENAME": "/var/www/html/status.php",
"REQUEST_METHOD": "GET",
"QUERY_STRING": "json&full",
},
})
If the context expires or is cancelled, the in-flight FastCGI request is aborted and the connection is returned to the pool in a clean state. This is critical for a sidecar that runs alongside PHP-FPM indefinitely.
Typed errors. Every error condition has its own type:
switch {
case errors.Is(err, fcgx.ErrConnRefused):
// FPM socket not available yet (startup)
case errors.Is(err, fcgx.ErrTimeout):
// FPM worker took too long
case errors.Is(err, fcgx.ErrClientClosed):
// Client was closed or connection lost
}
This lets FPM Exporter distinguish between "FPM is restarting" (retry) and "connection lost" (reconnect), which is exactly what a monitoring tool needs.
Connection management. The client maintains a pool of connections to the FPM socket and handles reconnection automatically. When FPM restarts and the unix socket is recreated, the client detects stale connections and reconnects without intervention:
client, err := fcgx.NewClient(
fcgx.WithNetwork("unix"),
fcgx.WithAddress("/var/run/php-fpm.sock"),
fcgx.WithMaxConnections(5),
fcgx.WithDialTimeout(2 * time.Second),
)
Parsing FPM Status Pages
The immediate use case for fcgx is parsing PHP-FPM's built-in status page. FPM exposes a status endpoint that returns pool statistics in several formats (plain text, JSON, XML). You request it by setting SCRIPT_FILENAME to the configured pm.status_path and optionally adding json and full to the query string.
The JSON response includes everything you need for monitoring:
{
"pool": "www",
"process manager": "dynamic",
"accepted conn": 48291,
"listen queue": 0,
"max listen queue": 12,
"idle processes": 5,
"active processes": 3,
"max active processes": 10,
"max children reached": 2
}
With the full parameter, you also get per-process details: current request URI, request duration, process state, and memory usage. This is what powers the per-worker metrics in FPM Exporter.
What I Would Do Differently
If I were starting fcgx today, I would add support for FastCGI multiplexing from the beginning. The current implementation uses one request per connection, which is simpler and sufficient for the FPM status page use case. But if you wanted to use this as a general-purpose FastCGI client for high-throughput applications, multiplexing would reduce the number of socket connections needed.
I would also add OpenTelemetry tracing hooks. Being able to trace a FastCGI request through a distributed system (load balancer to nginx to FPM to PHP) would make debugging production issues much easier. That's on the roadmap.
The fcgx library is used internally by FPM Exporter but is available as a standalone package for anyone who needs to talk FastCGI from Go. The full API reference is in the FPM Exporter docs.
// Sylvester Damgaard