A.k.a. a terrible, terrible hack. There are hints around this solution on the internets, but I couldn't find a good explanation of what's going on, so here you go.

How?

example.cfg

listen example
    mode http
    bind 127.0.0.1:8000
    errorfile 503 /tmp/example.http

/tmp/example.http

HTTP/1.0 400 Bad request
Cache-Control: no-cache
Connection: close
Content-Type: text/html

<html><body><h1>400 Bad request</h1>
This is an example.
</body></html>

Running

haproxy -f example.cfg

What? Why?

Alright, let's try this a different way.

example.cfg

frontend example-frn
    mode http
    bind 127.0.0.1:8000
    use_backend example-bck

backend example-bck
    mode http
    errorfile 503 /tmp/example.http

The other file is the same.

Seriously, what?

Here's the relevant part from the documentation.

errorfile <code> <file> returns a file contents instead of errors generated by HAProxy. [...] The files are returned verbatim on the TCP socket. This allows any trick such as redirections to another URL or site, as well as tricks to clean cookies, force enable or disable caching, etc... The package provides default error files returning the same contents as default errors.

Translation: we can tell HAProxy to, when serving a specific error code, instead of serving its built-in error content, serve the contents of a file. BUT! That file is "returned verbatim on the TCP socket", including HTTP headers and all. This allows all kinds of tricks, and one trick that isn't mentioned is changing the HTTP response code.

The last ingredient: if a listen or backend directive doesn't have any UP servers, it will return a 503.

Combining all these, we can say: hey HAProxy, here's a backend that will never have a server because I won't configure any. (So you'll always serve 503 on this backend). And oh, HAProxy, whenever you'd serve a 503, serve this file instead. (And the file just so happens to contain a 400 response, but it could be whatever else we want).

Caveats

Again, from the documentation:

The files should not exceed the configured buffer size (BUFSIZE), which generally is 8 or 16 kB, otherwise they will be truncated. It is also wise not to put any reference to local contents (eg: images) in order to avoid loops between the client and HAProxy when all servers are down, causing an error to be returned instead of an image. For better HTTP compliance, it is recommended that all header lines end with CR-LF and not LF alone.

The files are read at the same time as the configuration and kept in memory. For this reason, the errors continue to be returned even when the process is chrooted, and no file change is considered while the process is running. A simple method for developing those files consists in associating them to the 403 status code and interrogating a blocked URL.

So basically, keep the file small and simple. In practice this means that probably no user-facing error pages should be served by HAProxy; you're better of delegating that to a web server. This technique can still be useful for handling cases that users are not expected to encounter. For example in the hosting of this blog, I'm using it to restrict incoming requests to two domains, and return a descriptive error if HAProxy receives a request with a different Host header.

What's your favorite HAProxy trick?