Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP/2 with ntex-h2

ntex-h2 is a separate crate that implements HTTP/2 for ntex. Most applications use it indirectly through ntex::http or ntex::web. When a connection negotiates HTTP/2, ntex-h2 handles frames, header compression, streams, and flow control. ntex turns each stream into requests and responses.

Use ntex-h2 directly when you only need HTTP/2, need trailers or long-lived bidirectional streams, or want to manage flow control yourself. ntex-grpc uses it this way for both its client and its server.

For outgoing requests, client::Client opens and reuses a pool of connections to one server. Choose client::SimpleClient instead when you want to manage one connection yourself. On the receiving side, server::Server is an ntex service that handles HTTP/2 connections.

ntex-h2 speaks only HTTP/2 and does not negotiate the protocol. Any transport you give it must already be committed to HTTP/2. Over TLS, ALPN must have selected h2. Over plain TCP (h2c), both sides must know in advance that they will use HTTP/2.

The HTTP/1.1 Upgrade mechanism and server push are not supported.

Here is a client that sends a GET request over plain TCP and prints the response:

use ntex::SharedCfg;
use ntex::http::{HeaderMap, Method};
use ntex_h2::{MessageKind, StreamEof, client::Client};

#[ntex::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder("127.0.0.1:8080").build(SharedCfg::new("H2-CLIENT"));

    let (_send, recv) = client
        .send(Method::GET, "/index.html".into(), HeaderMap::new(), true)
        .await?;

    while let Some(msg) = recv.recv().await {
        match msg.kind {
            MessageKind::Headers { pseudo, .. } => println!("status: {:?}", pseudo.status),
            MessageKind::Data(data, _) => println!("data: {data:?}"),
            MessageKind::Eof(StreamEof::Data(data, _)) => println!("last data: {data:?}"),
            MessageKind::Eof(_) | MessageKind::Disconnect(_) => break,
        }
    }
    Ok(())
}

This API works with HTTP/2 streams and messages. Unlike ntex::client, it has no URLs, body helpers, or Response type.

Streams and messages

Each HTTP/2 stream carries one request and its response. Many independent streams can share a connection. The client opens a stream by sending request headers. Both sides can then send data, and each closes its own half when it is done.

Either side can cut a stream short with a reset. The connection and its other streams stay open.

A StreamRef identifies a stream and lets you send on it. It is cheap to clone. Everything that arrives on a stream reaches you as a Message:

pub struct Message {
    pub stream: StreamRef,
    pub kind: MessageKind,
}

A client and a server see the same sequence. It begins with Headers { pseudo, headers, eof }, the request or response head. The pseudo field holds HTTP/2’s pseudo-headers: method, scheme, authority, and path on requests, and status on responses. If eof is true, there is no body and this is the only message.

Otherwise, any number of Data(Bytes, Capacity) messages carry body chunks. Each Capacity represents the flow-control credit used by its chunk; the flow-control section explains when to release it.

Exactly one Eof(StreamEof) or Disconnect(err) ends the sequence. StreamEof::Data carries a final body chunk, StreamEof::Trailers carries trailing headers, and StreamEof::Error reports a failed or reset stream. Disconnect means the whole connection failed while the stream was open.

Send headers first, using send on a client or send_response on a server. Then send body chunks with send_payload, as many times as needed. To finish, set eof on the last call or send trailers with send_trailers.

Configuration

Start with limits that suit the requests your service handles. A large upload may need different receive windows from a small JSON call, and a shared connection needs room for several streams. ServiceConfig sets those limits and timeouts. Add it to a SharedCfg to apply it to every connection created with that configuration:

use ntex::{SharedCfg, time::Seconds};
use ntex_h2::ServiceConfig;

fn config() -> SharedCfg {
    SharedCfg::new("H2")
        .add(
            ServiceConfig::new()
                .set_max_concurrent_streams(128)
                .set_initial_window_size(256 * 1024)
                .set_initial_connection_window_size(4 * 1024 * 1024)
                .set_ping_timeout(Seconds(30)),
        )
        .into()
}

ntex::http exposes this same type as Http2ServiceConfig. These settings also apply to HTTP/2 in an ntex web server or HTTP client.

ntex-h2 connections use ordinary ntex I/O objects. Add an IoConfig to the same SharedCfg to control buffers and backpressure, as described in the I/O chapter.

The flow-control and frame settings determine how much data the peer may send:

SettingDefaultMeaning
set_initial_window_size64 KiB − 1Receive window of each stream: how much body data the peer may send on a stream before it must wait
set_initial_connection_window_size4 MiBReceive window shared by all streams of a connection
set_max_frame_size16 KiBLargest frame payload the peer may send. Allowed range is 16 KiB to 16 MiB − 1

Receive windows bound body data, but headers and stream bookkeeping also take space. Use these limits to bound the work and state a peer can create:

SettingDefaultMeaning
set_max_concurrent_streams256Streams the peer may have open at once. Further streams are refused with REFUSED_STREAM
set_max_headers96Header fields allowed in one header block. A stream with more is refused
set_max_header_list_size48 KiBTotal size of a header block
set_max_header_continuation_frames5CONTINUATION frames allowed for one header block
set_max_concurrent_reset_streams32Recently reset streams remembered so late frames for them are ignored; 0 disables the history
set_reset_stream_duration30 sHow long a reset stream is remembered
set_max_inflight_messages8192Messages waiting for your code. When the limit is reached, the connection stops reading until some are handled

Finally, keep a stalled peer from holding connection resources indefinitely. These timeouts cover the handshake and ongoing protocol exchanges:

SettingDefaultMeaning
set_handshake_timeout5 sTime allowed for the connection preface
set_settings_timeout5 sTime the peer has to acknowledge a SETTINGS frame
set_ping_timeout10 sKeep-alive interval. If a PING is not answered in time, the connection is closed
set_capacity_timeout5 sLongest a sender waits for flow-control window before the stream is reset; 0 disables it

Stream limits apply only to streams the peer opens. On a server, set_max_concurrent_streams caps how many requests a client may run at once. A client is bound by the limit the server announces.

ntex-h2 also guards against rapid-reset attacks. It closes a connection with GOAWAY if at least ten streams have been opened and half of them were reset.

The client

A client sends requests to one server. You can let a pool manage its connections or manage a single connection yourself.

Connecting with a pool

Use client::Client to reuse connections without managing them yourself. Like the HTTP client, create it once and keep it for the life of the application. Clones are cheap and share the same connections.

Each request needs a free stream slot, not a connection of its own. The pool looks for a connection that can open another stream and opens a new connection when needed. You can also ask it to grow to a minimum size with minconn.

Create the pool with a builder. Pass the configuration for all its connections to build:

use ntex::{SharedCfg, time::Seconds};
use ntex_h2::{ServiceConfig, client::Client};

fn backend() -> Client {
    let cfg = SharedCfg::new("BACKEND").add(ServiceConfig::new().set_ping_timeout(Seconds(30)));

    Client::builder("backend.internal:8080")
        .connect_timeout(Seconds(3))
        .connection_limit(4)
        .lifetime(Seconds(600))
        .build(cfg)
}

The builder has these options:

OptionDefaultMeaning
connectorTCPThe transport connector, for example a TLS connector
securefalseSets the :scheme of requests to https instead of http
connect_timeout1 sTime allowed to connect, including the TLS handshake
minconn1Below this many connections, the pool opens a new one for a request even if an existing connection has room
connection_limit16Maximum number of connections
max_streams100Streams a connection is assumed to accept until the server announces its own limit
lifetimenot setAge after which a connection is closed gracefully and replaced
disconnect_timeout15 sTime a closing connection has to finish its streams
skip_unknown_streamsoffIgnore frames for streams the client no longer knows instead of treating them as errors

When every connection is full and the pool is at its limit, send waits for a stream to free up. Errors before a stream opens, such as a failed connection attempt or a timeout, are reported as ClientError.

The pool opens new connections one at a time and spreads requests across them. It prefers connections using less than half of their stream limit.

Secure connections

To connect over TLS, give the pool a TLS connector that offers h2 through ALPN. Also set secure(true) so requests carry the https scheme. Here is an OpenSSL configuration:

use ntex::SharedCfg;
use ntex_h2::client::Client;
use openssl::ssl::{SslConnector, SslMethod};

fn secure_client() -> Result<Client, openssl::error::ErrorStack> {
    let mut builder = SslConnector::builder(SslMethod::tls())?;
    builder.set_alpn_protos(b"\x02h2")?;

    Ok(Client::builder("api.example.com:443")
        .connector(ntex::tls::openssl::SslConnector::new(builder.build()))
        .secure(true)
        .build(SharedCfg::new("API")))
}

The ALPN list b"\x02h2" is the protocol name h2 prefixed with its length. With rustls, set alpn_protocols to vec![b"h2".to_vec()] in its ClientConfig.

Unlike ntex::client, ntex-h2 cannot fall back to HTTP/1.1. If you also offer http/1.1, make sure the server will pick h2.

A single connection

Use client::SimpleClient to keep a session on one connection or run HTTP/2 over a transport you created. It wraps a single connection that you have already established:

use ntex::{SharedCfg, io::Io, util::ByteString};
use ntex_h2::client::SimpleClient;

async fn connect() -> Result<SimpleClient, Box<dyn std::error::Error>> {
    let cfg: SharedCfg = SharedCfg::new("H2").into();
    let io: Io = ntex::connect::connect_with("127.0.0.1:8080", &cfg).await?;

    Ok(SimpleClient::new(io, false, ByteString::from("127.0.0.1")))
}

The arguments are the transport, whether requests use the https scheme, and the :authority sent with each request. The connection reads its ServiceConfig from the transport’s SharedCfg.

To avoid setting this up by hand, use client::Connector. This ntex service takes an address and returns a SimpleClient.

SimpleClient is cheap to clone. The connection stays open while any clone exists and closes gracefully when the last one is dropped.

To stop earlier, call close(): open streams may finish, but no new ones can start. If you also need to wait until the connection is gone, use disconnect().await, with .disconnect_timeout(..) on the returned future to bound the wait. Reserve force_close() for cases where you must drop the connection immediately.

Use is_closed() to check whether the connection has closed. on_disconnect() tells you when it closes, whether you or the server initiated it.

A pool manages the connection lifecycle for you. If you need access to one of its connections, call Client::client(). It returns a SimpleClient from the pool that can open another stream.

Sending a request

Call send to start a request. Pass the method, the :path including any query string, the headers, and an eof flag.

It returns the two halves of the new stream. Use the SendStream for the request body and the RecvStream for the response.

use ntex::http::{HeaderMap, Method, header};
use ntex::util::Bytes;
use ntex_h2::client::Client;

async fn upload(client: &Client, body: Bytes) -> Result<(), Box<dyn std::error::Error>> {
    let mut headers = HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("application/octet-stream"));

    let (send, recv) = client.send(Method::POST, "/upload".into(), headers, false).await?;
    send.send_payload(body, true).await?;

    // ... read the response from `recv`
    drop(recv);
    Ok(())
}

Set eof to true when the request has no body, as with most GET requests. Otherwise, send the body with send_payload, which accepts anything that converts to Bytes. Pass eof: true on the last call. If the request has trailers, finish with send_trailers(headers) instead.

To stream a large body, call send_payload for each chunk:

use ntex::util::Bytes;
use ntex_h2::client::SendStream;
async fn stream_body(
    send: &SendStream,
    chunks: Vec<Bytes>,
) -> Result<(), ntex::error::Error<ntex_h2::OperationError>> {
    for chunk in chunks {
        send.send_payload(chunk, false).await?;
    }
    send.send_payload(Bytes::new(), true).await
}

You can read the response while still sending the request body. The two halves are independent, which supports bidirectional streaming protocols. Only one task should send on a stream at a time.

send_payload returns once it has handed the data to the connection. It splits the data into frames that fit the peer’s maximum frame size and the flow-control window. If the window is closed, it waits for the peer to reopen it (see Flow control).

It also waits while the connection’s write buffer is full. A fast sender cannot outrun the network.

Reading the response

Read a response from its RecvStream with recv().await. Each call returns the next message, or None once the response is complete. You can also use stream combinators because RecvStream implements Stream.

This function collects a whole response:

use ntex::http::{HeaderMap, StatusCode};
use ntex::util::{Bytes, BytesMut};
use ntex_h2::{MessageKind, StreamEof, client::RecvStream};

async fn read_response(
    recv: RecvStream,
) -> Result<(StatusCode, HeaderMap, Bytes), Box<dyn std::error::Error>> {
    let mut status = StatusCode::OK;
    let mut headers = HeaderMap::new();
    let mut body = BytesMut::new();

    while let Some(msg) = recv.recv().await {
        match msg.kind {
            MessageKind::Headers { pseudo, headers: hdrs, .. } => {
                status = pseudo.status.unwrap_or(StatusCode::OK);
                headers = hdrs;
            }
            MessageKind::Data(chunk, _capacity) => body.extend_from_slice(&chunk),
            MessageKind::Eof(StreamEof::Data(chunk, _capacity)) => {
                body.extend_from_slice(&chunk);
                break;
            }
            MessageKind::Eof(StreamEof::Trailers(_)) => break,
            MessageKind::Eof(StreamEof::Error(err)) => return Err(err.into()),
            MessageKind::Disconnect(err) => return Err(err.into()),
        }
    }
    Ok((status, headers, body.freeze()))
}

Each Data message comes with a Capacity. The example drops it right away, letting the server keep sending. Hold it to slow the server down, as Flow control explains.

Resets and cancellation

To cancel a request, drop either unfinished half of its stream. Dropping the RecvStream before the response is complete resets the stream with CANCEL. Dropping the SendStream before the request body is complete does the same. The server can then stop working on the request.

A timeout needs no extra cancellation code:

use ntex_h2::client::RecvStream;
async fn read_response(_: RecvStream) -> Result<(), Box<dyn std::error::Error>> { Ok(()) }
async fn example(recv: RecvStream) {
use ntex::time::{Seconds, timeout};

// If the timeout fires, `recv` is dropped and the stream is reset.
let result = timeout(Seconds(10), read_response(recv)).await;
}

Either side can reset a stream. Each reset carries a Reason code from ntex_h2::frame::Reason, such as CANCEL or INTERNAL_ERROR. To choose a reason, call reset(reason) on the SendStream or the StreamRef.

Use is_reset() to check whether either side has reset the stream. on_reset() tells you when a reset happens.

When the server resets a stream, you receive Eof(StreamEof::Error(..)). Further sends fail. When the whole connection fails, every open stream gets a Disconnect message instead.

Stream limits

A server announces how many streams it accepts at once on each connection. At that limit, SimpleClient::send waits until a stream finishes. The pool uses another connection or opens a new one.

On a SimpleClient, compare active_streams() with max_streams() to see how many slots are in use and what limit the server announced. Use is_ready() to check for a free slot or ready().await to wait for one. If your code is driven by callbacks, on_capacity(callback) runs whenever a stream is released.

Another task may take the free slot between your check and your send. Use reserve() to claim a slot immediately. It returns a StreamReservation, or None if the connection is full. The reservation’s send opens the stream without waiting. Dropping an unused reservation releases the slot.

Flow control

Flow control keeps one slow consumer from stalling every stream on a connection. TCP’s own flow control is not enough when many HTTP/2 streams share that connection.

HTTP/2 gives each stream a window of credit, measured in bytes of body data. The connection has another window, shared by all streams. A sender can send only as much data as both windows allow. Headers do not count against either window.

Each DATA frame uses credit from both windows. When either reaches zero, the sender must wait. The receiver grants more credit with a WINDOW_UPDATE frame.

Receiving data

Keep each chunk’s Capacity until you have handled the data. It represents the window credit the chunk used. Holding it keeps that credit in use, so the sender’s pace follows your consumer’s pace.

Drop the Capacity to return the credit to both the stream and connection windows. To release only part of it, call consume(n).

When you queue data for another task, pass the Capacity along with it. The task should drop it only after handling the data:

use ntex::channel::mpsc;
use ntex::util::Bytes;
use ntex_h2::{Capacity, MessageKind, StreamEof, client::RecvStream};

// Forward the body to a consumer task. The server can only send as fast
// as the consumer drops the capacities.
async fn forward(recv: RecvStream, tx: mpsc::Sender<(Bytes, Capacity)>) {
    while let Some(msg) = recv.recv().await {
        match msg.kind {
            MessageKind::Data(data, cap) => {
                if tx.send((data, cap)).is_err() {
                    break; // consumer is gone, dropping `recv` resets the stream
                }
            }
            MessageKind::Eof(StreamEof::Data(data, cap)) => {
                let _ = tx.send((data, cap));
                break;
            }
            MessageKind::Headers { .. } => {}
            MessageKind::Eof(_) | MessageKind::Disconnect(_) => break,
        }
    }
}

ntex-h2 batches WINDOW_UPDATE frames. It waits until released credit reaches a third of initial_window_size for a stream, or a quarter of initial_connection_window_size for the connection.

Unreleased data also counts against the shared connection window. A stream that buffers a lot of data without releasing its credit can slow down the others. The default connection window is therefore much larger than the stream window: 4 MiB versus 64 KiB per stream.

Size the windows together. The stream window limits how much a single request can buffer. The connection window limits the total across all requests.

If you merge chunks into one buffer, combine their capacities with + or += so you can release the credit together. Start with StreamRef::empty_capacity() for a zero-sized capacity belonging to that stream. Only combine capacities from the same stream; mixing streams panics.

If a peer sends more data than the window allows, the stream fails with StreamError::RecvWindowExceeded.

Sending data

Use send_payload to send data within the peer’s flow-control window. ntex-h2 tracks the granted credit and sends as much as the window allows. It waits for more credit when needed, until all the data is out.

If you produce data on demand, check capacity before generating a chunk. This avoids producing data that would sit in memory while you wait for the peer. available_send_capacity() reports the current window, which is the smaller of the stream’s and the connection’s window. To wait for credit before producing the next chunk, use send_capacity().await; it returns the available window size:

use ntex::util::Bytes;
use ntex_h2::client::SendStream;
fn produce(_: usize) -> Option<Bytes> { None }
async fn send_generated(send: &SendStream) -> Result<(), ntex::error::Error<ntex_h2::OperationError>> {
    loop {
        let window = send.send_capacity().await? as usize;
        match produce(window) {
            Some(chunk) => send.send_payload(chunk, false).await?,
            None => return send.send_payload(Bytes::new(), true).await,
        }
    }
}

A peer that never opens its window could leave a sender waiting forever. set_capacity_timeout bounds that wait to 5 seconds by default. If the wait exceeds the timeout, ntex-h2 resets the stream with CANCEL. The send fails with StreamError::CapacityTimeout.

The server

An ntex-h2 server gives your handlers direct access to stream messages. Your handlers send responses and keep any state they need for each stream.

Running a server

Use server::Server to serve HTTP/2 on an I/O object until the connection closes. It is an ntex service, so you can run it with ntex::server::build(), as described in the server chapter:

use ntex::http::{HeaderMap, StatusCode};
use ntex::{SharedCfg, error::Error};
use ntex_h2::{Message, MessageKind, OperationError, ServiceConfig, server};

#[ntex::main]
async fn main() -> std::io::Result<()> {
    let cfg = SharedCfg::new("H2-SRV").add(ServiceConfig::new().set_max_concurrent_streams(100));

    ntex::server::build()
        .bind("h2", "127.0.0.1:8080", cfg, async |_| {
            server::Server::new(async |msg: Message| {
                if let MessageKind::Headers { pseudo, .. } = msg.kind {
                    println!("{:?} {:?}", pseudo.method, pseudo.path);
                    msg.stream.send_response(StatusCode::OK, HeaderMap::new(), false)?;
                    msg.stream.send_payload("hello world", true).await?;
                }
                Ok::<_, Error<OperationError>>(())
            })
        })?
        .run()
        .await
}

Connections take their ServiceConfig from the SharedCfg passed to bind. This listener accepts plain TCP, so clients must know in advance that it speaks HTTP/2. For example, use curl --http2-prior-knowledge http://127.0.0.1:8080/.

For TLS, put a TLS acceptor in front of the server and have it select h2 during ALPN:

use ntex::{Service, SharedCfg, error::Error};
use ntex::tls::openssl::SslAcceptor;
use ntex_h2::{Message, OperationError, server};
use openssl::ssl::{self, AlpnError, SslFiletype, SslMethod};

#[ntex::main]
async fn main() -> std::io::Result<()> {
    let mut builder = ssl::SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
    builder.set_private_key_file("key.pem", SslFiletype::PEM).unwrap();
    builder.set_certificate_chain_file("cert.pem").unwrap();
    builder.set_alpn_select_callback(|_, protos| {
        if protos.windows(3).any(|w| w == b"\x02h2") {
            Ok(b"h2")
        } else {
            Err(AlpnError::NOACK)
        }
    });
    let acceptor = builder.build();

    ntex::server::build()
        .bind("h2", "127.0.0.1:8443", SharedCfg::new("H2-TLS"), async move |_| {
            SslAcceptor::new(acceptor.clone())
                .map_err(|e| server::ServerError::from(std::io::Error::other(e)))
                .and_then(server::Server::new(async |_msg: Message| {
                    // handle messages
                    Ok::<_, Error<OperationError>>(())
                }))
        })?
        .run()
        .await
}

To serve an I/O object you already have, call server::handle_one(io, publish, control) with services you have already created. The I/O object might come from your own acceptor or a connection that switched protocols. ntex-grpc runs its server this way.

Handling streams

Handle incoming messages in the publish service you pass to Server::new. ntex-h2 creates a new publish service for each connection. It calls the service for every message on every stream the client opens. The request headers, each body chunk, and the end of the stream arrive in separate calls.

These calls run concurrently. ntex-h2 starts the next call without waiting for the previous one to finish, so a slow request does not hold up others. The call that receives the headers cannot await the request body as part of that same call. Body data arrives through separate calls to the service.

Keep per-stream state in the publish service and route each message to where it is needed. A map keyed by StreamRef works for this because the handle implements Hash and Eq.

This service buffers each request body up to a size limit. Once the request is complete, it echoes the body back:

use std::{cell::RefCell, collections::HashMap};

use ntex::http::{HeaderMap, StatusCode};
use ntex::service::{Ctx, Service};
use ntex::util::BytesMut;
use ntex_h2::{Message, MessageKind, OperationError, StreamEof, StreamRef, frame::Reason};

const MAX_BODY: usize = 1024 * 1024;

#[derive(Default)]
struct Echo {
    bodies: RefCell<HashMap<StreamRef, BytesMut>>,
}

impl Service<(), Message> for Echo {
    type Res = ();
    type Error = ntex::error::Error<OperationError>;

    async fn call(&self, msg: Message, _: Ctx<'_, Self, ()>) -> Result<(), Self::Error> {
        let Message { stream, kind } = msg;

        let body = match kind {
            MessageKind::Headers { eof: true, .. } => BytesMut::new(),
            MessageKind::Headers { .. } => {
                self.bodies.borrow_mut().insert(stream, BytesMut::new());
                return Ok(());
            }
            MessageKind::Data(data, _) => {
                let mut bodies = self.bodies.borrow_mut();
                if let Some(body) = bodies.get_mut(&stream) {
                    body.extend_from_slice(&data);
                    if body.len() > MAX_BODY {
                        bodies.remove(&stream);
                        stream.reset(Reason::CANCEL);
                    }
                }
                return Ok(());
            }
            MessageKind::Eof(StreamEof::Data(data, _)) => {
                let Some(mut body) = self.bodies.borrow_mut().remove(&stream) else {
                    return Ok(());
                };
                body.extend_from_slice(&data);
                body
            }
            MessageKind::Eof(StreamEof::Trailers(_)) => {
                let Some(body) = self.bodies.borrow_mut().remove(&stream) else {
                    return Ok(());
                };
                body
            }
            MessageKind::Eof(StreamEof::Error(_)) | MessageKind::Disconnect(_) => {
                self.bodies.borrow_mut().remove(&stream);
                return Ok(());
            }
        };

        if body.len() > MAX_BODY {
            stream.reset(Reason::CANCEL);
            return Ok(());
        }
        stream.send_response(StatusCode::OK, HeaderMap::new(), false)?;
        stream.send_payload(body.freeze(), true).await
    }
}

The final Eof can carry data too, so the size limit must cover that last chunk as well as ordinary Data messages. Release each RefCell borrow before an await: other calls on the same service can run while this call is waiting.

Remove the map entry on Eof(StreamEof::Error(..)) or Disconnect. This keeps the map from leaking when a client goes away mid-request.

Wrap a service type like this in a factory. fn_factory takes an async function and calls it for each new connection:

use ntex::service::{Ctx, Service};
use ntex_h2::{Message, OperationError};
#[derive(Default)] struct Echo;
impl Service<(), Message> for Echo {
    type Res = ();
    type Error = ntex::error::Error<OperationError>;
    async fn call(&self, _: Message, _: Ctx<'_, Self, ()>) -> Result<(), Self::Error> { Ok(()) }
}
use ntex::service::fn_factory;
use ntex_h2::server;

fn server() -> server::Server<ntex::io::IoBoxed, ntex::error::Error<OperationError>, std::convert::Infallible> {
server::Server::new(fn_factory(async |_: &()| {
    Ok::<_, std::convert::Infallible>(Echo::default())
}))
}

For large or streaming bodies, store a channel sender in the map instead of buffering the body. Pass each chunk to a task that handles the request. Send the Capacity with the data, as in the flow control example. The client’s pace will then follow the task’s pace.

ntex::http builds request payloads on top of ntex-h2 this way.

Sending the response

Send a response through the request’s StreamRef. Start with send_response(status, headers, eof), setting eof to true if there is no body. Otherwise, follow it with send_payload(data, eof), just as on the client. You can finish with send_trailers(headers) instead of a final data chunk; gRPC uses trailers to carry its status.

If you need to send a 1xx response, such as 103 Early Hints, call send_informational(status, headers) before the final response head.

You can respond after the call that received the request has returned. Clone the StreamRef to store it or move it into another task.

StreamRef also has the client’s flow-control helpers. Use available_send_capacity to check capacity or send_capacity to wait for it.

Errors and resets

For a problem with one request, send an error response or reset its stream with stream.reset(reason). The connection and its other streams carry on. REFUSED_STREAM tells the client it may safely retry. CANCEL and INTERNAL_ERROR tell it the request failed.

Returning an error from the publish service affects the whole connection. ntex-h2 treats it as a connection fault and sends GOAWAY with INTERNAL_ERROR. It then closes the connection, failing every stream on it.

You can still use ? on send_response and send_payload. They fail only when the stream or connection is already gone. For request-level problems, reset the stream instead of returning an error.

If the client resets a stream while one of your calls for it is still running, that call is cancelled. You still receive an Eof message with the error, so you can clean up per-stream state as shown above.

The control service

Use a control service to log or react to connection-level events. The default control service accepts everything, and most servers can keep it. Pass your own service to .control(..) when you need different handling:

use ntex::error::Error;
use ntex_h2::{Control, Message, OperationError, server};

fn server() -> server::Server<ntex::io::IoBoxed, Error<OperationError>, std::io::Error> {
server::Server::new(async |_msg: Message| Ok::<_, Error<OperationError>>(()))
    .control(async |msg: Control<Error<OperationError>>| {
        if let Control::Disconnect(reason) = &msg {
            log::info!("connection closed: {reason:?}");
        }
        Ok::<_, std::io::Error>(msg.ack())
    })
}

Answer every control message with msg.ack().

Control::Disconnect tells you why a connection ended. The cause may be a protocol error, a GOAWAY from the client, the client going away, or an error returned by the publish service.

Server state

A request handler often needs both application data and connection details, such as a request counter and the peer address. Give the publish and control services that context through connection state. It travels with the I/O object and becomes the state of their pipelines, as described in Carrying state with a request.

A plain Io carries no state, so its state is (). This is why Echo implements Service<(), Message> and its factory takes &().

Worker state created by build_with_config() does not reach these services automatically. To pass it on, put a service in front of Server that reads the worker state and wraps the connection in a State. That is also a convenient place to record the peer address. See Worker and request state for how worker state is created.

The connection state must implement Clone because ntex-h2 clones it for the publish and control factories. Keep shared resources behind an Rc so those copies refer to the same data.

State-aware functions receive the connection state as their first argument. Service factories receive it in create:

use std::{cell::Cell, convert::Infallible, net::SocketAddr, rc::Rc};

use ntex::http::{HeaderMap, StatusCode};
use ntex::io::{Io, types::PeerAddr};
use ntex::service::{State, service};
use ntex::{SharedCfg, error::Error};
use ntex_h2::{Message, MessageKind, OperationError, server};

/// Worker state, created once per worker.
#[derive(Clone)]
struct AppState {
    requests: Rc<Cell<u64>>,
}

/// Connection state, shared by all streams of one connection.
#[derive(Clone)]
struct Conn {
    app: AppState,
    peer: Option<SocketAddr>,
}

async fn accept(
    app: &AppState,
    io: Io,
) -> Result<State<Conn, Io>, server::ServerError<Infallible>> {
    let peer = io.query::<PeerAddr>().get().map(|addr| addr.into_inner());
    let conn = Conn { app: app.clone(), peer };
    Ok(State { req: io, state: conn })
}

async fn publish(conn: &Conn, msg: Message) -> Result<(), Error<OperationError>> {
    if let MessageKind::Headers { pseudo, .. } = msg.kind {
        let requests = &conn.app.requests;
        requests.set(requests.get() + 1);
        println!("{:?} from {:?}, request #{}", pseudo.path, conn.peer, requests.get());

        msg.stream.send_response(StatusCode::OK, HeaderMap::new(), false)?;
        msg.stream.send_payload("hello world", true).await?;
    }
    Ok(())
}

#[ntex::main]
async fn main() -> std::io::Result<()> {
    ntex::server::build_with_config(async || {
        Ok(AppState {
            requests: Rc::new(Cell::new(0)),
        })
    })
    .bind("h2", "127.0.0.1:8080", SharedCfg::default(), async |_: &AppState| {
        service(accept).and_then(server::Server::new(publish))
    })?
    .run()
    .await
}

Each worker has its own AppState. The requests counter therefore counts only requests handled by that worker. Use an Arc for values that must be shared across workers.

Keep per-stream state in the publish service itself, as in the Echo example. A request body that is still arriving belongs there.

With handle_one, you create the publish and control pipelines yourself, so they hold whatever state you gave them.

Shutting down

During shutdown, let existing requests finish without accepting new work. Call ServiceConfig::shutdown() to shut down HTTP/2 connections gracefully. Servers on the current thread then refuse new streams, let active streams finish, and close their connections.

When the ntex server stops, its workers wait for connections to finish, as described in the server chapter.

What you take on

When you use ntex-h2 directly, you take responsibility for policies that ntex::http would otherwise handle. Plan to check that each body matches its content-length, enforce body size limits, and guard against floods of empty DATA frames. You also map stream messages onto requests and responses yourself.

The HTTP/2 example shows a server with buffered and streaming request bodies, backpressure, and trailers, plus a client that calls it. For more examples, see the ntex-h2 examples and the gRPC chapter.