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 <boost/corosio/ipv4_address.hpp>
#include <boost/capy/task.hpp>
#include <boost/capy/ex/run_async.hpp>
#include <boost/capy/buffers.hpp>
#include <boost/capy/buffers/string_dynamic_buffer.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

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
    std::string response;
    auto [ec, n] = co_await capy::read(
        stream, capy::string_dynamic_buffer(&response));

    // Reading into a dynamic buffer completes with success at EOF
    if (ec && ec != capy::cond::eof)
        throw std::system_error(ec);

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

Key points:

  • The write throws if writing fails

  • capy::read(stream, capy::string_dynamic_buffer(&response)) reads until EOF

  • Reading into a dynamic buffer completes with success at end-of-stream — it returns no error and reports the total bytes read. The != capy::cond::eof check is a harmless defensive guard, not the thing that signals end-of-response

The Connection Coroutine

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 (throws on error)
    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: http_client 35.190.118.110 80\n";
        return 1;
    }

    // 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 1;
    }

    auto port = static_cast<std::uint16_t>(std::atoi(argv[2]));

    corosio::io_context ioc;
    capy::run_async(ioc.get_executor())(
        run_client(ioc, addr, port));
    ioc.run();
}

Reading Until EOF

Wrapping a std::string in capy::string_dynamic_buffer lets capy::read grow it as data arrives, reading until EOF:

std::string response;
auto [ec, n] = co_await capy::read(
    stream, capy::string_dynamic_buffer(&response));

This:

  • Automatically grows the string as needed

  • Completes with success (no error) when the connection closes, since the dynamic-buffer read treats EOF as the natural end of the stream

  • Returns the total bytes read in n

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>

capy::task<void> do_request(
    corosio::tls_stream& stream,
    std::string_view host)
{
    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);

    std::string response;
    auto [ec, n] = co_await capy::read(
        stream, capy::string_dynamic_buffer(&response));
    // As with the plain stream, the dynamic-buffer read completes with
    // success at EOF; this check is a defensive guard.
    if (ec && ec != capy::cond::eof)
        throw std::system_error(ec);

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

capy::task<void> run_https_client(
    corosio::io_context& ioc,
    corosio::ipv4_address addr,
    std::uint16_t port,
    std::string_view hostname)
{
    corosio::tcp_socket s(ioc);
    s.open();

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

    // Configure the TLS context
    corosio::tls_context ctx;
    ctx.set_hostname(hostname);
    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);
    if (auto [ec] = co_await secure.handshake(
            corosio::wolfssl_stream::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