Skip to content

HTTP Client

An HTTP client is code that sends a request to a web server and reads the response.

An HTTP client is code that sends a request to a web server and reads the response.

When you open a web page, your browser acts as an HTTP client. When a command-line tool downloads a file, it may act as an HTTP client. When a program calls a web API, it usually uses an HTTP client.

HTTP is built around requests and responses.

A request asks for something:

GET / HTTP/1.1
Host: example.com

A response sends back a status, headers, and a body:

HTTP/1.1 200 OK
Content-Type: text/html

<html>...</html>

URLs

An HTTP request usually starts from a URL.

https://example.com/index.html

This has several parts.

https

is the scheme. It tells the client to use HTTPS.

example.com

is the host.

/index.html

is the path.

A URL may also contain a port, query string, username, password, or fragment. For beginners, scheme, host, and path are enough.

GET Requests

The most common HTTP method is GET.

A GET request asks the server for a resource.

Examples:

GET https://example.com/
GET https://api.example.com/users
GET https://example.com/file.txt

A Zig HTTP client call usually follows this shape:

const uri = try std.Uri.parse("https://example.com/");

Then the client uses that URI to make a request.

The exact std.http.Client API can change across Zig versions, so check your local documentation with:

zig std

The important ideas are stable:

create a client

parse a URL

send a request

read the response

close or deinitialize resources

A Conceptual HTTP GET

A simple HTTP GET program has this shape:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    var client = std.http.Client{
        .allocator = allocator,
    };
    defer client.deinit();

    const uri = try std.Uri.parse("https://example.com/");

    // Send GET request.
    // Read response body.
    // Print response body.
}

This shows the resource pattern.

The allocator belongs to the general purpose allocator.

The HTTP client uses the allocator.

The client is deinitialized with defer.

The URI parse can fail because the URL may be invalid.

Request and Response

HTTP has two sides.

The request contains:

method

URL

headers

optional body

The response contains:

status code

headers

optional body

A basic GET request usually has no body.

A POST request often has a body.

For example, a JSON API request may send:

{"name":"Alice"}

and receive:

{"ok":true}

Status Codes

The status code tells you what happened.

CodeMeaning
200OK
201Created
204No content
301Moved permanently
302Found or redirected
400Bad request
401Unauthorized
403Forbidden
404Not found
500Server error

Do not assume a request succeeded only because the network call returned.

The request may complete successfully at the transport level but still return:

404 Not Found

or:

500 Internal Server Error

Your program should inspect the status code.

Headers

Headers are key-value metadata.

Example request headers:

User-Agent: my-tool/1.0
Accept: application/json
Authorization: Bearer TOKEN

Example response headers:

Content-Type: application/json
Content-Length: 123
Cache-Control: max-age=60

Headers are still text. Their meaning depends on the HTTP protocol and the server.

Response Body

The response body is the main data returned by the server.

It might be:

HTML

JSON

plain text

an image

a compressed file

binary data

Your program decides how to interpret the body.

For an API, you may parse the body as JSON.

For a downloader, you may write the body to a file.

For a health check, you may only inspect the status code.

Reading the Whole Body

For small responses, reading the whole body into memory is convenient.

Conceptually:

const body = try response.reader().readAllAlloc(allocator, max_size);
defer allocator.free(body);

The max_size matters.

Never read an untrusted HTTP response into memory without a limit.

A server could send a huge response and exhaust your memory.

The pattern should include a maximum:

const max_body_size = 1024 * 1024;

This means 1 MiB.

Streaming the Body

For large responses, stream the body.

The shape is:

read chunk from response
write chunk to file
repeat until done

This avoids storing the whole response in memory.

Streaming is the right approach for:

large downloads

logs

media

archives

database exports

unknown-size responses

The same pattern appeared in file I/O and compression. Systems programming repeats this idea often.

Timeouts

HTTP clients should use timeouts.

Without timeouts, a program can hang for a long time if the server stops responding.

There are different kinds of timeouts:

connection timeout

read timeout

total request timeout

idle timeout

The exact API depends on the HTTP client implementation, but the design rule is simple:

Network programs should not wait forever by accident.

HTTPS

Most modern HTTP traffic uses HTTPS.

HTTPS means HTTP over TLS.

TLS encrypts the connection and verifies the server identity.

When using HTTPS, the client must support certificates and TLS verification.

Do not disable certificate verification in production code. That defeats a major part of HTTPS security.

Sending JSON

Many APIs accept JSON.

A request may need:

method POST

header Content-Type: application/json

JSON body

Conceptually:

POST /users HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"name":"Alice"}

In Zig, you can format or stringify the JSON body, then send it as bytes.

The important rule is:

JSON is text, but HTTP sends bytes.

So the request body is a []const u8.

Parsing JSON Responses

If the server returns JSON, parse it after reading the body.

Conceptual shape:

const parsed = try std.json.parseFromSlice(
    Response,
    allocator,
    body,
    .{},
);
defer parsed.deinit();

const value = parsed.value;

This combines HTTP and JSON:

HTTP retrieves bytes.

JSON parsing turns bytes into typed data.

Keep those layers separate in your thinking.

User-Agent

Some servers expect a User-Agent header.

A good command-line tool should identify itself.

Example:

User-Agent: my-zig-tool/0.1

This helps server operators understand traffic and debug problems.

Do not pretend to be a browser unless you have a specific compatibility reason.

Redirects

A server may respond with a redirect.

For example:

301 Moved Permanently
Location: https://www.example.com/

Some HTTP clients follow redirects automatically. Others require you to handle them.

For tools, make this behavior deliberate.

A downloader may follow redirects.

A security-sensitive client may want stricter rules.

Errors

HTTP code can fail at many layers.

The URL may be invalid.

DNS lookup may fail.

The TCP connection may fail.

TLS verification may fail.

The server may close the connection.

The response may be too large.

The status code may indicate failure.

The JSON body may be invalid.

A good program distinguishes these cases when it matters.

At minimum, do not collapse every error into “request failed” unless the caller truly does not need detail.

A Small API Client Shape

Here is the shape of a small typed API client:

const std = @import("std");

const User = struct {
    id: u64,
    name: []const u8,
};

fn fetchUser(
    allocator: std.mem.Allocator,
    id: u64,
) !std.json.Parsed(User) {
    var client = std.http.Client{
        .allocator = allocator,
    };
    defer client.deinit();

    var url_buffer: [256]u8 = undefined;
    const url = try std.fmt.bufPrint(
        &url_buffer,
        "https://api.example.com/users/{}",
        .{id},
    );

    const uri = try std.Uri.parse(url);

    // Send HTTP GET.
    // Read body with a maximum size.
    // Parse body as User.
    // Return parsed JSON object.
}

The function signature is important:

fn fetchUser(
    allocator: std.mem.Allocator,
    id: u64,
) !std.json.Parsed(User)

It says:

the function may allocate

the function may fail

the result owns parsed memory

the caller must call deinit

Avoid Mixing Too Much in One Function

A clean HTTP program usually has layers.

One function builds the URL.

One function sends the request.

One function checks the status.

One function parses JSON.

One function handles user-facing errors.

This keeps code testable.

For example:

fn buildUserUrl(buffer: []u8, id: u64) ![]u8
fn fetchBytes(allocator: std.mem.Allocator, url: []const u8) ![]u8
fn parseUser(allocator: std.mem.Allocator, body: []const u8) !std.json.Parsed(User)

Small functions make network code easier to reason about.

Common Mistakes

Do not read unlimited response bodies into memory.

Do not ignore status codes.

Do not treat HTTP success and API success as the same thing.

Do not disable TLS verification casually.

Do not log secret headers such as Authorization.

Do not assume the response body is valid JSON.

Do not forget to deinitialize the HTTP client or free response buffers.

Do not build URLs with unchecked user input.

The Core Pattern

For a basic HTTP client:

var client = std.http.Client{
    .allocator = allocator,
};
defer client.deinit();

const uri = try std.Uri.parse("https://example.com/");

// send request
// read response
// inspect status
// use body

For JSON APIs:

const body = try read_response_body_with_limit;

const parsed = try std.json.parseFromSlice(
    ResponseType,
    allocator,
    body,
    .{},
);
defer parsed.deinit();

For large downloads:

open output file
read response chunks
write chunks to file
close output file

What You Should Remember

An HTTP client sends requests and reads responses.

A URL identifies the target resource.

A response has a status code, headers, and body.

A successful network operation can still return an HTTP error status.

Read small bodies into memory only with a size limit.

Stream large bodies.

Use HTTPS correctly.

Treat response bodies as external input.

HTTP client code is mostly resource management, error handling, byte handling, and protocol discipline.