Skip to content

# HTTP Client

The Glaze HTTP client provides a simple and efficient way to make HTTP requests with connection pooling and asynchronous operations.

> Prerequisites: This feature requires ASIO. See the ASIO Setup Guide for installation instructions. For HTTPS connections, OpenSSL is also required.

## Basic Usage

    #include "glaze/net/http_client.hpp"

    int main() {
        glz::http_client client;

        auto response = client.get("https://example.com");

        if (response) {
            std::cout << "Status: " << response->status_code << std::endl;
            std::cout << "Body: " << response->response_body << std::endl;

            // Access response headers
            for (const auto& [name, value] : response->response_headers) {
                std::cout << name << ": " << value << std::endl;
            }
        } else {
            std::cerr << "Error: " << response.error().message() << std::endl;
        }

        return 0;
    }

## Features

- Connection Pooling: Automatically reuses connections for better performance, with stale-connection detection (timestamp eviction + active TCP peek)
- Transparent Retry: Idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE, TRACE) are retried once on connection-level failures when the server has not yet started responding. POST and PATCH are never auto-retried.
- Chunked Transfer-Encoding: Transparent decoding of chunked responses across synchronous, asynchronous, and streaming paths
- Asynchronous Operations: Non-blocking requests with futures or completion handlers
- JSON Support: Built-in JSON serialization for POST requests
- Thread-Safe: Multiple threads can safely use the same client instance
- Error Handling: Uses `std::expected` for clean error handling

## HTTPS Trust Store Configuration

When using HTTPS, certificate verification depends on your OpenSSL trust store configuration. To simplify setup, `http_client` provides:

    std::expected<void, std::error_code> configure_system_ca_certificates(
        std::optional<std::string_view> cert_bundle_file = std::nullopt
    );

Fallback order:

1. Explicit `cert_bundle_file` argument (if provided)
2. `SSL_CERT_FILE` environment variable
3. `SSL_CERT_DIR` environment variable
4. OpenSSL default verify paths (`set_default_verify_paths`)

Example:

    glz::http_client client;

    if (auto ec = client.configure_system_ca_certificates("/path/to/cert.pem"); !ec) {
        std::cerr << "Failed to configure CA trust roots: " << ec.error().message() << '\n';
        return;
    }

    auto response = client.get("https://example.com");

Notes:

- On macOS with Homebrew OpenSSL, you may need to point `SSL_CERT_FILE` at Homebrew's CA bundle (commonly `/opt/homebrew/etc/ca-certificates/cert.pem`).
- OpenSSL certificate lookup behavior is platform/package-manager dependent; explicit configuration is recommended for reproducible deployments.

## Synchronous Methods

### GET Request

    std::expected<response, std::error_code> get(
        std::string_view url,
        const std::unordered_map<std::string, std::string>& headers = {}
    );

### POST Request

    std::expected<response, std::error_code> post(
        std::string_view url,
        std::string_view body,
        const std::unordered_map<std::string, std::string>& headers = {}
    );

### JSON POST Request

    template<class T>
    std::expected<response, std::error_code> post_json(
        std::string_view url,
        const T& data,
        const std::unordered_map<std::string, std::string>& headers = {}
    );

## Asynchronous Methods

All asynchronous methods come in two variants:

1. Future-based: Returns a `std::future` for the response
2. Callback-based: Takes a completion handler that's called when the operation completes

### Async GET Request

Future-based:

    std::future<std::expected<response, std::error_code>> get_async(
        std::string_view url,
        const std::unordered_map<std::string, std::string>& headers = {}
    );

Callback-based:

    template<typename CompletionHandler>
    void get_async(
        std::string_view url,
        const std::unordered_map<std::string, std::string>& headers,
        CompletionHandler&& handler
    );

### Async POST Request

Future-based:

    std::future<std::expected<response, std::error_code>> post_async(
        std::string_view url,
        std::string_view body,
        const std::unordered_map<std::string, std::string>& headers = {}
    );

Callback-based:

    template<typename CompletionHandler>
    void post_async(
        std::string_view url,
        std::string_view body,
        const std::unordered_map<std::string, std::string>& headers,
        CompletionHandler&& handler
    );

### Async JSON POST Request

Future-based:

    template<class T>
    std::future<std::expected<response, std::error_code>> post_json_async(
        std::string_view url,
        const T& data,
        const std::unordered_map<std::string, std::string>& headers = {}
    );

Callback-based:

    template<class T, typename CompletionHandler>
    void post_json_async(
        std::string_view url,
        const T& data,
        const std::unordered_map<std::string, std::string>& headers,
        CompletionHandler&& handler
    );

## Streaming Requests

The HTTP client supports streaming requests, which allow you to receive data in chunks.

    std::shared_ptr<http_stream_connection> stream_request_v2(const stream_request_params_v2& params);

The `stream_request_params_v2` struct contains the following fields:

    struct stream_request_params_v2 {
        std::string method{"GET"};
        std::string url;
        std::chrono::seconds timeout{30s};
        stream_read_strategy strategy{stream_read_strategy::bulk_transfer};
        size_t max_buffer_size{1024 * 1024};
        std::string body;
        std::unordered_map<std::string, std::string> headers;
        http_connect_handler on_connect;
        http_disconnect_handler on_disconnect;
        http_data_handler on_data;
        http_error_handler on_error;
        std::function<bool(int)> status_is_error{[](int status){ return status >= 400; }};
    };

- `method`: The HTTP method to use. (default is "GET")
- `url`: The URL to request.
- `timeout`: Set connection timeout. (default is 30s)
- `strategy`: Can be `bulk_transfer` (default, larger chunks, better throughput) or `immediate_delivery` (smaller chunks, lower latency)
- `max_buffer_size`: Larger buffer can decrease dropouts and increase throughput at cost of memory usage. (default is 1 MiB)
- `body`: The HTTP Body to send.
- `headers`: The HTTP headers to send.
- `on_connect`: A callback that's called when the connection is established and the headers are received.
- `on_disconnect`: A callback that's called when the connection is closed.
- `on_data`: A callback that's called when data is received.
- `on_error`: A callback that's called when an error occurs.
- `status_is_error`: Optional predicate to decide whether a status code should trigger `on_error` (defaults to checking for codes ≥ 400).

To override the default behaviour you can supply a predicate:

    auto conn = client.stream_request_v2({
        .url = "http://localhost/typesense",
        .on_data = on_data,
        .on_error = on_error,
        .status_is_error = [](int status) { return status >= 500; } // Ignore 4xx responses
    });

The `http_stream_connection` object contains a `disconnect()` method that can be used to close the connection.

### Handling HTTP Errors During Streaming

When the server responds with an HTTP error (status code ≥ 400) the client immediately invokes `on_error` with an `std::error_code` whose category is `glz::http_status_category()`. You can extract the numeric status code by comparing the category directly or by using the helper `glz::http_status_from(ec)`:

    auto on_error = [](std::error_code ec) {
        if (auto status = glz::http_status_from(ec)) {
            std::cerr << "Server failed with HTTP status " << *status << "\n";
            return;
        }

        // Fallback for transport errors
        std::cerr << "Stream error: " << ec.message() << "\n";
    };

## Response Structure

The `response` object contains:

    struct response {
        uint16_t status_code;                                      // HTTP status code
        std::unordered_map<std::string, std::string> response_headers; // Response headers
        std::string response_body;                                 // Response body
    };

## Error Handling

The HTTP client returns a `std::expected` object for synchronous and asynchronous requests, which contains either the response or an error code. You can check for errors using the `has_value()` method or by accessing the `error()` method.

    auto response = client.get("https://example.com");

    if (response) {
        // Request was successful
        std::cout << "Status: " << response->status_code << std::endl;
    } else {
        std::error_code ec = response.error();
        std::cerr << "Error: " << ec.message() << std::endl;
    }

For streaming requests, errors are reported via the `on_error` callback in the `stream_options` struct. The client translates HTTP error statuses (4xx/5xx) into `std::errc::connection_refused` errors.

## Examples

### Simple GET Request

    #include "glaze/net/http_client.hpp"

    int main() {
        glz::http_client client;

        auto response = client.get("https://api.github.com/users/octocat");

        if (response) {
            std::cout << "Status: " << response->status_code << std::endl;
            std::cout << "Content-Type: " << response->response_headers["Content-Type"] << std::endl;
            std::cout << "Body: " << response->response_body << std::endl;
        } else {
            std::cerr << "Error: " << response.error().message() << std::endl;
        }

        return 0;
    }

### POST Request with Custom Headers

    #include "glaze/net/http_client.hpp"

    int main() {
        glz::http_client client;

        std::unordered_map<std::string, std::string> headers = {
            {"Content-Type", "text/plain"},
            {"Authorization", "Bearer your-token"}
        };

        auto response = client.post("https://api.example.com/data", "Hello, World!", headers);

        if (response) {
            std::cout << "Status: " << response->status_code << std::endl;
            std::cout << "Response: " << response->response_body << std::endl;
        } else {
            std::cerr << "Error: " << response.error().message() << std::endl;
        }

        return 0;
    }

### JSON POST Request

    #include "glaze/net/http_client.hpp"
    #include "glaze/glaze.hpp"

    struct User {
        int id;
        std::string name;
        std::string email;
    };

    int main() {
        glz::http_client client;

        User user{123, "John Doe", "john@example.com"};

        // Using the convenient post_json method
        auto response = client.post_json("https://api.example.com/users", user);

        if (response) {
            std::cout << "User created! Status: " << response->status_code << std::endl;
            std::cout << "Response: " << response->response_body << std::endl;
        } else {
            std::cerr << "Error: " << response.error().message() << std::endl;
        }

        return 0;
    }

### Asynchronous Requests with Futures

    #include "glaze/net/http_client.hpp"
    #include <future>
    #include <vector>

    int main() {
        glz::http_client client;

        // Launch multiple async requests
        std::vector<std::future<std::expected<glz::response, std::error_code>>> futures;

        futures.push_back(client.get_async("https://api.github.com/users/octocat"));
        futures.push_back(client.get_async("https://api.github.com/users/defunkt"));
        futures.push_back(client.get_async("https://api.github.com/users/pjhyett"));

        // Wait for all requests to complete
        for (auto& future : futures) {
            auto response = future.get();
            if (response) {
                std::cout << "Status: " << response->status_code << std::endl;
            } else {
                std::cerr << "Error: " << response.error().message() << std::endl;
            }
        }

        return 0;
    }

### Asynchronous Requests with Callbacks

    #include "glaze/net/http_client.hpp"
    #include <iostream>

    int main() {
        glz::http_client client;

        // Async GET with callback
        client.get_async("https://api.github.com/users/octocat", {},
            [](std::expected<glz::response, std::error_code> result) {
                if (result) {
                    std::cout << "Async GET completed! Status: " << result->status_code << std::endl;
                } else {
                    std::cerr << "Async GET failed: " << result.error().message() << std::endl;
                }
            });

        // Async JSON POST with callback
        struct Data { int value = 42; };
        Data data;

        client.post_json_async("https://httpbin.org/post", data, {},
            [](std::expected<glz::response, std::error_code> result) {
                if (result) {
                    std::cout << "Async JSON POST completed! Status: " << result->status_code << std::endl;
                } else {
                    std::cerr << "Async JSON POST failed: " << result.error().message() << std::endl;
                }
            });

        // Keep the main thread alive long enough for async operations to complete
        std::this_thread::sleep_for(std::chrono::seconds(2));

        return 0;
    }

## URL Parsing

The client includes a URL parsing utility:

    #include "glaze/net/http_client.hpp"

    auto url_parts = glz::parse_url("https://api.example.com:8080/v1/users");
    if (url_parts) {
        std::cout << "Protocol: " << url_parts->protocol << std::endl; // "https"
        std::cout << "Host: " << url_parts->host << std::endl;         // "api.example.com"
        std::cout << "Port: " << url_parts->port << std::endl;         // 8080
        std::cout << "Path: " << url_parts->path << std::endl;         // "/v1/users"
    }

## Chunked Transfer-Encoding

Responses using `Transfer-Encoding: chunked` are automatically decoded. This is transparent to the caller -- `response_body` contains the fully assembled body regardless of whether the server used `Content-Length` or chunked encoding.

- Chunk extensions (`;key=value`) are ignored per RFC 7230
- Trailer headers after the terminal chunk are consumed and discarded
- Malformed chunk sizes return a `protocol_error`

For streaming requests (`stream_request_v2`), chunked data is delivered incrementally via the `on_data` callback as each chunk arrives.

## Connection Pool

`http_client` keeps idle connections for reuse, keyed on `(host, port, scheme)`. A pooled connection is reused only if it passes two checks at acquire time:

1. Idle timeout. Entries returned to the pool more than `pool_idle_timeout` ago are evicted before reuse. The default is 4 seconds, chosen to be just under uvicorn's default 5s `--timeout-keep-alive`. Tune to slightly less than your server's keep-alive idle timeout if it differs.
2. Active liveness check. For plain TCP sockets, a non-blocking `MSG_PEEK` checks whether the peer has already sent FIN or RST. SSL sockets skip the active peek (it would only see ciphertext) and rely on timestamp eviction plus the transparent-retry path.

    glz::http_client client;

    // Sized for a server with a 10-second keep-alive idle timeout
    client.set_pool_idle_timeout(std::chrono::seconds(8));

    // Allow more idle connections to a single host (default 10, in line with urllib3)
    client.set_pool_max_connections_per_host(64);

    // Disable the active peek check (relies on timestamp + retry instead). Mainly
    // useful for testing or for trading a syscall per acquire for slightly higher
    // recovery latency on stale connections.
    client.set_pool_active_liveness_check(false);

    // Drop and close every pooled connection. Useful in tests, or when a host has
    // signalled (e.g. via 503) that all current sessions should be abandoned.
    client.clear_connection_pool();

### Performance considerations

- Pool capacity. The default cap of 10 connections per host matches urllib3 / requests; OkHttp uses 5, Java HttpClient uses 6 per the HTTP/1.1 RFC's recommended 6 per origin. If your peak per-host concurrency exceeds the cap, returns above the cap are closed rather than pooled, and every request beyond the K-th active connection pays the full connect + TLS handshake cost. For sustained concurrency above the default, raise the cap to at least your observed steady-state per-host concurrency.
- Worker threads. When constructed without an external executor, `http_client` runs worker threads internally to drive its own `io_context`. When constructed with an external executor, the caller is responsible for running it (e.g. `io_ctx.run()`).

## Transparent Retry

Connection-level failures (`EOF`, `ECONNRESET`, `EPIPE`, `ECONNABORTED`, `ENOTCONN`, `ESHUTDOWN`) are retried exactly once on a fresh connection when all of the following hold:

- The method is idempotent: `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`, `TRACE`.
- The server has not yet started sending a response on this attempt (the header read has not completed).

This behavior covers the two real failure modes you'll hit in production:

- Stale pooled connection. A kept-alive connection idles past the server's keep-alive timeout (uvicorn defaults to 5s). The local TCP stack still reports the socket as open, the request write goes into the OS send buffer, and the read fails with EOF or RST. Retry on a fresh connection succeeds.
- Fresh-socket failure during write. The server accepts the connection but cannot process the request (listener overload, server crash between `accept` and `read`, immediate close after accept for rate limiting). The write succeeds locally; the read fails. Retry on a fresh connection succeeds.

Once any response bytes have been received on the wire, retry is suppressed even on idempotent methods, so a mid-body RST does not cause the client to silently re-issue a request the server already processed.

### POST and PATCH

`POST` and `PATCH` are never auto-retried. The client cannot distinguish between "request not delivered" and "request delivered, response lost," so retrying could silently double-execute a non-idempotent operation. If you need retry semantics for POST/PATCH, build them at the application layer with idempotency keys or explicit retry tokens.

    auto resp = client.post("https://api.example.com/payments", body);
    if (!resp) {
        // Handle the error explicitly. The request may or may not have been
        // processed by the server; consult application-level idempotency state
        // before deciding to retry.
    }

### Disabling automatic retry

There is no global "disable retry" switch by design: the retry conditions are conservative enough that disabling them would only change behavior in cases where retrying is correct. If the test scenario requires it, you can raise the bar by lowering `pool_idle_timeout` so stale entries are evicted (rather than reused and retried) and the failure modes that drive retry don't arise in the first place.