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:
|
Overview
Making an HTTP request involves:
-
Creating and opening a socket
-
Connecting to the server
-
Sending the HTTP request
-
Reading the response
-
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::readfills 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
responseby whatever each read delivers -
Ends when the connection closes:
capy::readreportscapy::error::eofonce 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
-
DNS Lookup — Resolve hostnames to addresses
-
TLS Guide — WolfSSL integration details
-
Composed Operations — How read/write work