HTTP Client Tutorial

This tutorial builds a simple HTTP client that connects to a server, sends a GET request, and reads the response. You’ll learn socket connection, composed I/O operations, and the exception-based error handling pattern.

Code snippets assume:

#include <boost/corosio.hpp>
#include <system_error>
#include <boost/capy/task.hpp>
#include <boost/capy/ex/run_async.hpp>
#include <boost/capy/buffers.hpp>
#include <boost/capy/error.hpp>
#include <boost/capy/read.hpp>
#include <boost/capy/write.hpp>

namespace corosio = boost::corosio;
namespace capy = boost::capy;

Overview

Making an HTTP request involves:

  1. Creating and opening a socket

  2. Connecting to the server

  3. Sending the HTTP request

  4. Reading the response

  5. Handling connection close (EOF)

We’ll use the exception-based pattern, throwing std::system_error on failure, for concise code.

Building the Request

HTTP/1.1 requests have a simple text format:

std::string build_request(std::string_view host)
{
    return "GET / HTTP/1.1\r\n"
           "Host: " + std::string(host) + "\r\n"
           "Connection: close\r\n"
           "\r\n";
}

The Connection: close header tells the server to close the connection after sending the response. This simplifies our code because we know EOF marks the end of the response.

The Request Coroutine

// Coroutine that performs the HTTP GET request
capy::task<void>
do_request(
    corosio::io_stream& stream,
    std::string_view host)
{
    // Build and send the request
    std::string request = build_request(host);
    if (auto [ec, n] = co_await capy::write(
            stream, capy::const_buffer(request.data(), request.size())); ec)
        throw std::system_error(ec);

    // Read the entire response until EOF, one fixed chunk at a time
    std::string response;
    for (;;)
    {
        char chunk[4096];
        auto [ec, n] = co_await capy::read(
            stream, capy::mutable_buffer(chunk, sizeof(chunk)));
        response.append(chunk, n);
        if (ec)
        {
            // EOF is expected when the server closes the connection
            if (ec != capy::error::eof)
                throw std::system_error(ec);
            break;
        }
    }

    std::cout << response << std::endl;
}

Key points:

  • The write throws if writing fails

  • Each capy::read fills the chunk completely unless the stream ends or fails first; the loop appends whatever arrived and goes around again

  • EOF (capy::error::eof) is the expected way the loop ends: the server closes the connection after the response, so it is not treated as a failure

The Connection Coroutine

// Parent coroutine that creates and connects the socket
capy::task<void>
run_client(
    corosio::io_context& ioc,
    corosio::ipv4_address addr,
    std::uint16_t port)
{
    corosio::tcp_socket s(ioc);
    s.open();

    // Connect to the server
    if (auto [ec] = co_await s.connect(corosio::endpoint(addr, port)); ec)
        throw std::system_error(ec);

    co_await do_request(s, addr.to_string());
}

The socket must be opened before connecting. We pass the socket as an io_stream& to do_request, so the same function works with any plain socket. TLS streams have a different type and need their own overload, as shown below.

Main Function

int
main(int argc, char* argv[])
{
    if (argc != 3)
    {
        std::cerr <<
            "Usage: http_client <ip-address> <port>\n"
            "Example:\n"
            "    http_client 35.190.118.110 80\n";
        return EXIT_FAILURE;
    }

    // Parse IP address
    corosio::ipv4_address addr;
    if (auto ec = corosio::parse_ipv4_address(argv[1], addr); ec)
    {
        std::cerr << "Invalid IP address: " << argv[1] << "\n";
        return EXIT_FAILURE;
    }

    // Parse port
    int port_int = std::atoi(argv[2]);
    if (port_int <= 0 || port_int > 65535)
    {
        std::cerr << "Invalid port: " << argv[2] << "\n";
        return EXIT_FAILURE;
    }
    auto port = static_cast<std::uint16_t>(port_int);

    // Create I/O context and run
    corosio::io_context ioc;
    capy::run_async(ioc.get_executor())(
        run_client(ioc, addr, port));
    ioc.run();

    return EXIT_SUCCESS;
}

Reading Until EOF

Reading a fixed-size chunk at a time and appending to a std::string accumulates the whole response, however large it is:

std::string response;
for (;;)
{
    char chunk[4096];
    auto [ec, n] = co_await capy::read(
        stream, capy::mutable_buffer(chunk, sizeof(chunk)));
    response.append(chunk, n);
    if (ec)
    {
        // EOF is expected when the server closes the connection
        if (ec != capy::error::eof)
            throw std::system_error(ec);
        break;
    }
}

This:

  • Grows response by whatever each read delivers

  • Ends when the connection closes: capy::read reports capy::error::eof once the server has sent everything

  • Treats any other error as a genuine failure and throws

Error vs. Exception Patterns

This example uses exceptions because:

  • Connection errors are fatal—we want to abort

  • The code is more linear without error checks

Compare structured bindings:

auto [ec] = co_await s.connect(ep);
if (ec)
{
    std::cerr << "Connect failed: " << ec.message() << "\n";
    co_return;
}

With exceptions:

if (auto [ec] = co_await s.connect(ep); ec)  // Throw on error
    throw std::system_error(ec);

Both are valid. Use exceptions when errors are exceptional; use structured bindings when errors are expected (like EOF during reading).

Running the Client

First, find an IP address for a website:

$ nslookup www.example.com
...
Address: 93.184.215.14

Then run the client:

$ ./http_client 93.184.215.14 80
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
...
<!doctype html>
<html>
...
</html>

Adding TLS Support

To make HTTPS requests, wrap the connected socket in a wolfssl_stream. A wolfssl_stream is not an io_stream, so it needs its own do_request overload taking corosio::tls_stream&:

#include <boost/corosio/wolfssl_stream.hpp>

// Coroutine that performs the HTTPS GET request
capy::task<void>
do_request(
    corosio::tls_stream& stream,
    std::string_view host)
{
    // Build and send the request
    std::string request = build_request(host);
    if (auto [ec, n] = co_await capy::write(
            stream, capy::const_buffer(request.data(), request.size())); ec)
        throw std::system_error(ec);

    // Read the entire response until EOF, one fixed chunk at a time
    std::string response;
    for (;;)
    {
        char chunk[4096];
        auto [ec, n] = co_await capy::read(
            stream, capy::mutable_buffer(chunk, sizeof(chunk)));
        response.append(chunk, n);
        if (ec)
        {
            // EOF is expected when the server closes the connection
            if (ec != capy::error::eof)
                throw std::system_error(ec);
            break;
        }
    }

    std::cout << response << std::endl;
}

// Parent coroutine that creates and connects the socket
capy::task<void>
run_client(
    corosio::io_context& ioc,
    corosio::ipv4_address addr,
    std::uint16_t port,
    std::string_view hostname)
{
    corosio::tcp_socket s(ioc);
    s.open();

    // Connect to the server
    if (auto [ec] = co_await s.connect(corosio::endpoint(addr, port)); ec)
        throw std::system_error(ec);

    // Configure TLS context
    corosio::tls_context ctx;
    if (auto ec = ctx.set_default_verify_paths(); ec)
        throw std::system_error(ec);
    if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::peer); ec)
        throw std::system_error(ec);

    // Wrap the connected socket without taking ownership (pointer form)
    corosio::wolfssl_stream secure(&s, ctx);
    secure.set_hostname(hostname);

    // Perform TLS handshake
    if (auto [ec] = co_await secure.handshake(corosio::tls_role::client); ec)
        throw std::system_error(ec);

    co_await do_request(secure, hostname);

    if (auto [ec] = co_await secure.shutdown(); ec)
        throw std::system_error(ec);
}

The TLS overload mirrors the plain one: capy::read and capy::write work with tls_stream exactly as they do with io_stream. Only the parameter type and the surrounding handshake/shutdown differ.

Next Steps