Skip to content

std.net.http: HTTP Client & Server

The HTTP stack that respects your profile.


std.net.http is Janus’ native HTTP/1.1 implementation – client and server in a single module. It follows the tri-signature pattern: the same function names work across :core (blocking), :service (async with Context), and :sovereign (capability-gated with audit trail).

The module is built on a two-layer architecture:

  • Zig bridge layer (std/bridge/http_bridge.zig) – raw Linux syscalls, C-exported functions, zero allocation
  • Janus application layer (std/net/http/) – protocol logic, types, pooling, routing, capabilities

The bridge handles bytes. Janus handles meaning.


use zig "std/bridge/http_bridge"
func main() do
let response = http_get(allocator, "http://api.example.com/status")
catch |err| do
println("Request failed: ", err)
return
end
println("Status: ", response.status.code)
println("Body: ", response.body)
response.deinit()
end
let response = http_post(
allocator,
"http://api.example.com/data",
"{\"key\": \"value\"}"
) catch |err| do
fail err
end
defer response.deinit()
if response.status.code != 200 do
fail HttpError.InvalidResponse
end
let options = RequestOptions{
.timeout_ms = 5000,
.follow_redirects = true,
.max_redirects = 3,
}
let response = http_request(
allocator,
HttpMethod.PUT,
"http://api.example.com/resource/42",
options,
) catch |err| do
fail err
end

Every HTTP function has three signatures – one per profile. Same name, rising honesty:

ProfileClient SignatureWhat Changes
:corehttp_get(allocator, url)Blocking, no timeout, teaching-simple
:servicehttp_get(allocator, url, ctx)Context with deadline + cancellation
:sovereignhttp_get_sovereign(allocator, url, cap)Capability-gated, audit trail

Blocking. Single-threaded. No pool. The simplest path from URL to response:

let resp = http_get(allocator, "http://httpbin.org/get") catch |err| do
println("Error: ", err)
return
end

Context-aware. Deadlines propagate. Cancellation is cooperative:

let ctx = Context{
.deadline_ns = now_ns() + 5_000_000_000, // 5 second deadline
.cancelled = &cancel_flag,
}
let resp = http_get_with_ctx(allocator, url, ctx) catch |err| do
match err {
HttpError.Timeout => println("Deadline exceeded"),
HttpError.Cancelled => println("Request cancelled"),
_ => println("Error: ", err),
}
return
end

Zero ambient authority. Every request validated against a capability token:

let cap = CapNetHttp.init(allocator)
.allowHost("api.example.com")
.allowScheme("https")
.allowMethod(.GET)
.allowMethod(.POST)
.maxResponseBodySize(10 * 1024 * 1024)
let resp = http_get_sovereign(allocator, url, &cap) catch |err| do
match err {
HttpError.HostNotAllowed => println("Capability denied: host not in allow-list"),
HttpError.SchemeNotAllowed => println("Capability denied: scheme not allowed"),
_ => println("Error: ", err),
}
return
end

For production use with connection pooling, redirects, and keep-alive:

const config = HttpClientConfig{
.timeout_ms = 10000,
.max_redirects = 5,
.follow_redirects = true,
}
var client = HttpClient.init(config, allocator)
defer client.deinit()
// Connections are pooled automatically (6 per host, 64 total)
let resp1 = client.get("http://api.example.com/users") catch |e| fail e
defer resp1.deinit()
// Second request reuses the connection
let resp2 = client.get("http://api.example.com/posts") catch |e| fail e
defer resp2.deinit()

The pool is transparent – you don’t manage it directly:

  • Per-host limit: 6 connections (HTTP/1.1 best practice)
  • Total limit: 64 connections
  • Idle timeout: 60 seconds (lazy eviction on acquire)
  • Keep-alive: Respected automatically from Connection header
  • Thread-safe: Mutex-protected for :service concurrent use

Follows RFC 7231 semantics:

StatusBehavior
301, 302Method becomes GET, body dropped
307, 308Method and body preserved

Cross-origin redirects (different scheme+host+port) automatically strip the Authorization header. Loop detection and max-hops enforcement prevent infinite chains.

For streaming request/response bodies:

// Decoding (response)
// Handled automatically by the response parser when
// Transfer-Encoding: chunked is detected
// Encoding (request body)
// Set body to Body.Chunked with a BodyReader

func handler(request: *ServerRequest, writer: *ResponseWriter) !void do
writer.setStatus(200)
.setHeader("Content-Type", "text/plain")
.send("Hello from Janus!")
end
// Blocking, sequential, single-threaded
httpServe(8080, handler, allocator)

Builder pattern for constructing responses:

func api_handler(req: *ServerRequest, w: *ResponseWriter) !void do
// JSON response
w.setStatus(200).json("{\"status\": \"ok\"}")
// HTML response
w.setStatus(200).html("<h1>Hello</h1>")
// No body (204)
w.setStatus(204).sendNoBody()
// Streaming (chunked)
var chunked = w.startChunked()
chunked.writeChunk("chunk 1")
chunked.writeChunk("chunk 2")
chunked.finish()
// Server-Sent Events
var sse = w.startEventStream()
sse.sendEvent("update", "{\"count\": 42}")
sse.sendData("plain data")
end

Pattern matching with path parameters and wildcards:

var router = Router.init(allocator)
defer router.deinit()
router.get("/api/health", health_handler)
router.get("/api/users/:id", user_handler)
router.post("/api/users", create_user_handler)
router.route(.DELETE, "/api/users/:id", delete_user_handler)
router.get("/static/*path", static_file_handler)

Path parameters are extracted into RouteParams:

func user_handler(req: *ServerRequest, w: *ResponseWriter) !void do
let user_id = req.params.get("id") catch |_| do
w.setStatus(400).send("Missing user ID")
return
end
// ... fetch user by user_id
end

Dispatch rules:

  • No path match → 404 Not Found
  • Path matches, wrong method → 405 Method Not Allowed (with Allow header)
  • First-match-wins ordering
var chain = MiddlewareChain.init(allocator)
defer chain.deinit()
// Global middleware
chain.use(logging_middleware)
chain.use(cors_middleware)
// Path-scoped middleware
chain.usePrefix("/api/", auth_middleware)

Thread-per-connection with back-pressure:

var server = HttpServer.init(ServerConfig{
.port = 8080,
.max_connections = 256,
.shutdown_timeout_ms = 30000,
}, allocator)
// Spawns a thread per connection, semaphore-limited
server.serveAsync(router_handler)
// Graceful shutdown: drain in-flight, then force-close
server.shutdown()

GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS

Case-insensitive parsing via HttpMethod.fromString().

const status = HttpStatus.fromCode(200)
// status.code == 200
// status.reason == "OK"

Named constants: ok, created, no_content, bad_request, not_found, internal_server_error, plus all redirect codes (301, 302, 307, 308).

Status code is u16 – honest about the 3-digit range.

Case-insensitive, multi-value, insertion-order iteration:

var headers = Headers.init(allocator)
defer headers.deinit()
headers.set("Content-Type", "application/json")
headers.add("Accept", "text/html")
headers.add("Accept", "application/json")
let ct = headers.get("content-type") // case-insensitive
let all_accept = headers.getAll("Accept") // returns both values

Total header size tracked against 16 KiB limit (configurable). Non-ASCII rejected with HttpError.InvalidRequest.

Tagged union representing request/response bodies:

VariantUse Case
EmptyHEAD responses, 204 No Content
FixedKnown-length body (Content-Length)
StreamStreaming via BodyReader
ChunkedChunked transfer encoding

Ownership rules:

  • Outbound Fixed: Caller owns the bytes, body borrows
  • Inbound Fixed: Parser allocates via caller’s allocator, caller frees

RFC 3986 parsing with scheme validation:

let url = URL.parse(allocator, "https://api.example.com:8443/v1/users?page=2")
defer url.deinit()
// url.scheme == "https"
// url.host == "api.example.com"
// url.port == 8443
// url.path == "/v1/users"
// url.query == "page=2"

Only http and https schemes accepted. Default ports: 80 for HTTP, 443 for HTTPS.


All errors are values (per SPEC-032). No payloads – the variant name IS the diagnostic:

ErrorMeaning
ConnectionRefusedTarget host rejected the connection
ConnectionResetConnection dropped mid-flight
ConnectionClosedPeer closed cleanly
DnsResolutionFailedHostname could not be resolved
TimeoutDeadline exceeded
CancelledRequest cancelled via Context
ErrorMeaning
InvalidRequestMalformed HTTP request
InvalidResponseMalformed HTTP response
ChunkedEncodingErrorBad chunked frame
ResponseTooLargeBody exceeds max size (default 10 MiB)
RequestTooLargeRequest body exceeds server limit
TooManyRedirectsRedirect chain exceeded max hops
ErrorMeaning
HostNotAllowedHost not in capability allow-list
SchemeNotAllowedScheme not in capability allow-list
CapabilityDeniedOperation blocked by capability gate
ErrorMeaning
TlsHandshakeFailedTLS negotiation failed
CertificateInvalidCertificate validation failed
CertificateExpiredCertificate past validity date

CapNetHttp controls what the HTTP client is allowed to do:

let cap = CapNetHttp.init(allocator)
.allowHost("api.internal.com")
.allowHost("cdn.internal.com")
.allowScheme("https")
.allowMethod(.GET)
.allowMethod(.POST)
.maxRequestBodySize(1 * 1024 * 1024) // 1 MiB
.maxResponseBodySize(10 * 1024 * 1024) // 10 MiB

Zero ambient authority: An empty capability denies everything. No hosts, no schemes, no methods – you must explicitly grant each permission.

Ambient-deny-all: When :core code runs at :sovereign profile, a default capability is injected that denies all requests. The code compiles, but every HTTP call fails at runtime with the appropriate error. This is Syntactic Honesty – the cost of running teaching code in a sovereign context is visible, not hidden.

CapNetHttpServe controls what the server can bind and serve:

let cap = CapNetHttpServe.init(allocator)
.allowBind("127.0.0.1", 8080)
.allowPathPrefix("/api/")
.allowPathPrefix("/health")
.maxConnections(100)

Every capability check emits a record if an AuditSink is provided:

// AuditRecord:
// timestamp_ns: i64
// capability_type: .net_http | .net_http_serve
// operation: "connect" | "bind" | "check_host" | ...
// target: "api.example.com:443"
// result: .allowed | .denied

Records are stack-allocated – zero heap overhead per check. If no sink is provided, records are silently discarded.


TLS is abstracted behind TlsTransport – a swappable trait:

// Current: stub backend (returns TlsHandshakeFailed)
// Future: Zig std.crypto.tls backend
// Future: janus.crypto.noise backend

The abstraction boundary is in place. When HTTPS is needed in production, a real backend slots in behind the trait without changing the API surface.

ProfilePlain HTTPHTTPS
:coreAllowed everywhereOptional
:serviceWarning for non-localhostRecommended
:sovereignBlocked for external hostsRequired

:sovereign allows plain HTTP to localhost, 127.0.0.1, and ::1 for local development.


┌─────────────────────────────────────────────┐
│ Janus Application Layer (std/net/http/) │
│ Types, parsing, pooling, routing, caps │
├─────────────────────────────────────────────┤
│ Garden Wall (bridge wrappers in client.zig)│
│ Negative codes → typed HttpError variants │
├─────────────────────────────────────────────┤
│ Zig Bridge (std/bridge/http_bridge.zig) │
│ Raw Linux syscalls, C-exported functions │
└─────────────────────────────────────────────┘

The Garden Wall is the boundary where raw bridge return codes (-1, -2, -3) become typed HttpError variants. Negative codes never leak above this layer.

std/net/http.zig # Sovereign Index (re-exports public API)
std/net/http/
├── errors.zig # HttpError (23 variants)
├── types.zig # HttpMethod, HttpStatus, Headers, Body
├── url.zig # URL parsing (RFC 3986)
├── streaming.zig # BodyReader / BodyWriter traits
├── config.zig # HttpConfig, HttpClientConfig, HttpResponse
├── client.zig # Garden Wall wrappers + :core functions
├── request.zig # HttpRequest builder + serialization
├── response_parser.zig # Response parser state machine
├── chunked.zig # Chunked transfer codec
├── pool.zig # Connection pool (mutex-protected)
├── redirect.zig # Redirect handler (301/302/307/308)
├── http_client.zig # HttpClient struct (pool + redirect)
├── server.zig # HttpServer, ServerRequest, ResponseWriter
├── router.zig # Router (exact, :param, *wildcard)
├── middleware.zig # Middleware chain (global + path-scoped)
├── capability.zig # CapNetHttp, CapNetHttpServe
├── audit.zig # AuditSink, AuditRecord
├── tls.zig # TlsTransport trait, TlsConfig, TlsPolicy
├── tls_backend.zig # Stub TLS backend
└── sbi_hooks.zig # SBI content negotiation hooks

std.net.http includes hooks for SBI (Sovereign Binary Interface) content negotiation:

  • Content-Type: application/x-sbi detection
  • X-SBI-Schema header for schema fingerprint validation
  • Accept header dispatch across sbi, json, and plain formats

These are detection-only hooks – actual SBI encode/decode uses the std.sbi module.


ModeTargetNotes
Blocking (:core)25,000 req/sSingle-threaded, no pool
Async (:service)50,000+ req/sThread-per-connection, pooled
  • Explicit allocators – no hidden GC pressure
  • Connection pooling – amortizes TCP handshake cost
  • Lazy eviction – no background timer, stale connections pruned on acquire
  • Stack-allocated audit records – zero heap per capability check
  • Direct syscalls in bridge – no libc overhead for I/O path

Deliberate scope exclusions (Mechanism over Policy):

FeatureStatusRationale
HTTP/2DeferredComplexity doesn’t justify teaching cost
WebSocketOut of scopeDifferent protocol, different module
DNS cachingOut of scopeOS-level caching is sufficient
Retry logicOut of scopeMechanism over Policy – caller decides retry strategy
Cookie jarOut of scopeApplication-level concern
CompressionDeferredgzip/deflate as future bridge extension

VersionChange
v2026.3.13Initial release – complete client/server stack, 236 tests