Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`http_version` selects the protocols to serve** (default
`['HTTP/1.1']`). The option was documented but read by nothing; it now
drives which listeners bind. `'HTTP/2'` and `'HTTP/3'` exist only over
TLS and are refused with
`{error, {http_version_requires_ssl, Version}}` when `ssl` is off,
rather than quietly serving HTTP/1.1.
- `['HTTP/1.1', 'HTTP/2']` serves both from one TLS port, chosen per
connection by ALPN, so a client that cannot speak HTTP/2 is still
served rather than refused. This restores what cowboy's `start_tls`
did before the livery move.
- `'HTTP/3'` adds a QUIC listener, on the `bind` port number by
default or on the new `http3_port`, and advertises it to HTTP/1.1
and HTTP/2 clients through `Alt-Svc`. Its certificate and key are
converted from the configured PEM files to the DER shapes quic
expects.
- `hornbeam:info/0` reports every bound protocol. Following livery
0.8.0, `listeners` maps each protocol to a **list** of ports, because
one port can serve two protocols and one protocol can be on several
ports.
- **`max_request_line_size`, `max_header_size` and `max_headers` are
enforced.** They were accepted and documented but reached nothing. A
breach is now answered `414` or `431` instead of being ignored.

### Fixed

- **A HEAD response over HTTP/2 or HTTP/3 crashed the handler.** The
ASGI and WSGI producers only handled `{error, closed}` from a stream
write, and h2/h3 answer `{error, invalid_stream_state}` when a body is
written to a stream that must not carry one, where HTTP/1.1 silently
drops it.

### Changed

- **HTTP server moved from cowboy to livery**: hornbeam now serves HTTP
through [livery](https://github.com/benoitc/livery) instead of cowboy.
- The listener is owned by a new supervised `hornbeam_listener`
process; `hornbeam:info/0` and `hornbeam:is_running/0` replace ranch
introspection.
- `hornbeam_handler` is a livery handler; WSGI and ASGI responses
resolve through `livery_resp:stream_deferred/1` in the per-request
process (one mailbox for request-body chunks and Python events).
- WebSocket runs on the `ws_handler` behaviour (new
`hornbeam_ws_handler`); `hornbeam_websocket` keeps the upgrade
entry point, scope building, and the session registry.
- Duplicate request headers are preserved in the ASGI scope and
joined with `", "` in WSGI `HTTP_*` keys.
- New `max_body` option (default `infinity`) caps the request body at
the listener.
- Requires livery 0.8.0: 103 Early Hints go out through
`livery_req:inform/3`, WSGI `REMOTE_ADDR` and the ASGI `client` /
WebSocket scope carry the real peer, `websocket_max_frame_size` and
`websocket_compress` are forwarded to `livery_ws:upgrade/3`, and
ASGI `websocket.disconnect` reports the peer's own close code
(1005 when it closed without one).
- **HTTP client moved from hackney to livery_client**: the test suites
call `livery_client` directly and match its response map; hackney is no
longer a direct dependency, and the shim that kept hackney's
`request/5` shape is gone.

### Removed (breaking)

- **`routes` option shape changed**: routes are livery router entries
`{Method | '_', Pattern, HandlerFun | {Mod, Fun}}` (optionally with a
meta map) instead of cowboy `{Path, HandlerModule, Opts}` tuples.
Cowboy handler modules are rejected with `{error, {invalid_route, _}}`.
- **`hornbeam_wsgi` module removed**: the classic `build_environ/1,2`
path was dead code (the live path is
`hornbeam_request:build_wsgi_tuple/2`).

### Known limitations

- 103 Early Hints are sent on HTTP/1.1 and HTTP/2 but not HTTP/3, which
has no interim responses in livery yet; the
`http.response.early_hints` ASGI extension is advertised accordingly.

- **erlang_python v3.0**: Track the simplified execution model
- Switched dep to erlang_python `main` (worker / owngil modes only)
- `config/sys.config`: replaced obsolete `num_workers` key with `num_contexts`
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The name combines "horn" (unicorn, like gunicorn) with "BEAM" (Erlang VM).
- **WSGI Support**: Run standard WSGI Python applications
- **ASGI Support**: Run async ASGI Python applications (FastAPI, Starlette, etc.)
- **WebSocket**: Full WebSocket support for real-time apps
- **HTTP/2**: Via Cowboy, with multiplexing and server push
- **HTTP via livery**: HTTP/1.1 today; HTTP/2 and HTTP/3 listeners land with upcoming livery releases
- **Shared State**: ETS-backed state accessible from Python (concurrent-safe)
- **Distributed RPC**: Call functions on remote Erlang nodes
- **Pub/Sub**: pg-based publish/subscribe messaging
Expand Down Expand Up @@ -227,7 +227,8 @@ hornbeam:start("myapp:application", #{

%% Protocol
worker_class => wsgi, % wsgi | asgi
http_version => ['HTTP/1.1', 'HTTP/2'],
%% 'HTTP/2' and 'HTTP/3' require ssl => true
http_version => ['HTTP/1.1'],

%% Workers
workers => 4,
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/asgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The scope dict contains request information:
|-----|------|-------------|
| `type` | str | `"http"` |
| `asgi` | dict | `{"version": "3.0"}` |
| `http_version` | str | `"1.1"` or `"2"` |
| `http_version` | str | `"1.1"`, `"2"` or `"3"` |
| `method` | str | HTTP method |
| `scheme` | str | `"http"` or `"https"` |
| `path` | str | URL path (stripped of mount prefix in [multi-app mode](/docs/guides/multi-app)) |
Expand Down Expand Up @@ -367,8 +367,8 @@ hornbeam:start("app:app", #{
workers => 4,
timeout => 30000,

%% HTTP
http_version => ['HTTP/1.1', 'HTTP/2']
%% HTTP; 'HTTP/2' and 'HTTP/3' require ssl => true
http_version => ['HTTP/1.1']
}).
```

Expand Down
5 changes: 3 additions & 2 deletions docs/guides/benchmarking.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,12 @@ hornbeam:start("app:application", #{

### HTTP/2 for Lower Latency

Enable HTTP/2 for multiplexed connections:
Enable HTTP/2 for multiplexed connections. Listing HTTP/1.1 alongside it
serves both from the one TLS port, chosen per connection by ALPN:

```erlang
hornbeam:start("app:application", #{
http_version => ['HTTP/2', 'HTTP/1.1'],
http_version => ['HTTP/1.1', 'HTTP/2'],
ssl => true,
certfile => "server.crt",
keyfile => "server.key"
Expand Down
33 changes: 20 additions & 13 deletions docs/guides/custom-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,12 @@ def distributed_inference(texts):
return results
```

## Custom Cowboy Routes
## Custom Erlang Routes

Add custom routes alongside your Python app:
Add custom routes alongside your Python app. A route is
`{Method | '_', Pattern, Handler}` where Handler is a `fun/1` or
`{Module, Function}` taking a livery request and returning a livery
response:

```erlang
%% Start with custom routes
Expand All @@ -344,29 +347,33 @@ hornbeam:start("app:app", #{
pythonpath => ["priv/python"],
routes => [
%% Health check - pure Erlang, no Python
{"/health", health_handler, []},
{<<"GET">>, <<"/health">>, fun health_handler:handle/1},

%% Metrics endpoint
{"/metrics", metrics_handler, []},
{<<"GET">>, <<"/metrics">>, {metrics_handler, handle}},

%% WebSocket with custom handler
{"/ws/[...]", my_websocket_handler, []}
{'_', <<"/ws/*rest">>, fun my_websocket_handler:handle/1}
]
}).
```

```erlang
%% health_handler.erl
-module(health_handler).
-export([init/2]).
-export([handle/1]).

init(Req, State) ->
Reply = cowboy_req:reply(200,
#{<<"content-type">> => <<"application/json">>},
<<"{\"status\":\"ok\"}">>,
Req
),
{ok, Reply, State}.
handle(_Req) ->
livery_resp:json(200, <<"{\"status\":\"ok\"}">>).
```

A WebSocket route upgrades from inside its handler with a module
implementing the `ws_handler` behaviour:

```erlang
%% my_websocket_handler.erl
handle(Req) ->
livery_ws:upgrade(Req, my_ws_session, #{idle_timeout => 60000}).
```

## Configuration
Expand Down
17 changes: 6 additions & 11 deletions docs/guides/erlang-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,8 @@ cd my_app
{vm_args, "config/vm.args"}
]}.

{profiles, [
{test, [
{deps, [
{hackney, "3.0.2"}
]}
]}
]}.
%% livery (already a hornbeam dependency) provides the HTTP client
%% used in the test suite below; no extra test dependency needed.
```

### Project Structure
Expand Down Expand Up @@ -519,18 +514,18 @@ def get_from_remote(key):
all() -> [test_api].

init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(hackney),
{ok, _} = application:ensure_all_started(my_app),
Config.

end_per_suite(_Config) ->
application:stop(my_app),
application:stop(hackney),
ok.

test_api(_Config) ->
{ok, 200, _Headers, ClientRef} = hackney:request(get, <<"http://localhost:8000/">>, [], <<>>, []),
{ok, Body} = hackney:body(ClientRef),
Client = livery_client:new(#{}),
{ok, Resp} = livery_client:get(Client, <<"http://localhost:8000/">>),
200 = livery_client:status(Resp),
{full, Body} = livery_client:body(Resp),
true = is_binary(Body),
ok.
```
Expand Down
46 changes: 44 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ See [Multi-App Guide](/docs/guides/multi-app) for detailed usage.
|--------|------|---------|-------------|
| `bind` | binary/string | `"127.0.0.1:8000"` | Address and port to bind to |
| `ssl` | boolean | `false` | Enable SSL/TLS |
| `certfile` | binary | `undefined` | Path to SSL certificate |
| `keyfile` | binary | `undefined` | Path to SSL private key |
| `certfile` | binary | `undefined` | Path to SSL certificate (PEM) |
| `keyfile` | binary | `undefined` | Path to SSL private key (PEM) |
| `cacertfile` | binary | `undefined` | Path to CA certificate |
| `http3_port` | integer | `undefined` | UDP port for HTTP/3; defaults to the `bind` port |

### SSL Example

Expand All @@ -100,6 +101,43 @@ hornbeam:start("app:application", #{
}).
```

### HTTP versions

`http_version` picks which protocols to serve. It defaults to
`['HTTP/1.1']`. `'HTTP/2'` and `'HTTP/3'` exist only over TLS, so both
require `ssl => true`; asking for either without it fails at startup with
`{error, {http_version_requires_ssl, Version}}` rather than silently
serving HTTP/1.1.

```erlang
hornbeam:start("app:application", #{
bind => "0.0.0.0:443",
ssl => true,
certfile => "/path/to/cert.pem",
keyfile => "/path/to/key.pem",
http_version => ['HTTP/1.1', 'HTTP/2', 'HTTP/3']
}).
```

| Setting | What binds |
|---------|------------|
| `['HTTP/1.1']` | One TCP listener, cleartext or TLS depending on `ssl` |
| `['HTTP/1.1', 'HTTP/2']` | One TLS port serving both, chosen per connection by ALPN |
| `['HTTP/2']` | One TLS port, HTTP/2 only; a client that cannot do h2 is refused |
| `['HTTP/3']` added | A QUIC listener on `http3_port`, advertised to HTTP/1.1 and HTTP/2 clients with `Alt-Svc` |

HTTP/3 can share the `bind` port number because QUIC is UDP and the other
listeners are TCP.

`hornbeam:info/0` reports what actually bound:

```erlang
#{running => true, listeners => #{h1 => [443], h2 => [443], h3 => [443]}}
```

Each protocol maps to a list of ports, since one port can serve two
protocols and one protocol can appear on more than one port.

## Protocol Options

| Option | Type | Default | Description |
Expand Down Expand Up @@ -159,6 +197,10 @@ hornbeam:start("app:app", #{
| `max_header_size` | integer | `8190` | Max HTTP header value length |
| `max_headers` | integer | `100` | Max number of HTTP headers |

A request over the request-line limit is answered `414 URI Too Long`; one
over either header limit is answered `431 Request Header Fields Too Large`.
These apply to the HTTP/1.1 and ALPN listeners.

## Python Options

| Option | Type | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion examples/embedding_chat/src/embedding_chat_app.erl
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ start(_StartType, _StartArgs) ->
pythonpath => [priv_dir(), VenvSitePackages],
%% /ws handled by Erlang, rest by FastAPI
routes => [
{"/ws", embedding_chat_ws, #{}}
{'_', <<"/ws">>, fun embedding_chat_ws:handle/1}
]
}),

Expand Down
Loading
Loading