std.net.http: HTTP Client & Server
std.net.http
Section titled “std.net.http”The HTTP stack that respects your profile.
What is std.net.http?
Section titled “What is std.net.http?”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.
Quick Start
Section titled “Quick Start”Simple GET Request (:core)
Section titled “Simple GET Request (:core)”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()endPOST with Body
Section titled “POST with Body”let response = http_post( allocator, "http://api.example.com/data", "{\"key\": \"value\"}") catch |err| do fail errenddefer response.deinit()
if response.status.code != 200 do fail HttpError.InvalidResponseendFull Control
Section titled “Full Control”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 errendTri-Signature Pattern
Section titled “Tri-Signature Pattern”Every HTTP function has three signatures – one per profile. Same name, rising honesty:
| Profile | Client Signature | What Changes |
|---|---|---|
:core | http_get(allocator, url) | Blocking, no timeout, teaching-simple |
:service | http_get(allocator, url, ctx) | Context with deadline + cancellation |
:sovereign | http_get_sovereign(allocator, url, cap) | Capability-gated, audit trail |
:core – The Monastery
Section titled “:core – The Monastery”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) returnend:service – The Bazaar
Section titled “:service – The Bazaar”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), } returnend:sovereign – The Citadel
Section titled “:sovereign – The Citadel”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), } returnendClient API
Section titled “Client API”HttpClient
Section titled “HttpClient”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 edefer resp1.deinit()
// Second request reuses the connectionlet resp2 = client.get("http://api.example.com/posts") catch |e| fail edefer resp2.deinit()Connection Pool
Section titled “Connection Pool”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
Connectionheader - Thread-safe: Mutex-protected for
:serviceconcurrent use
Redirect Handling
Section titled “Redirect Handling”Follows RFC 7231 semantics:
| Status | Behavior |
|---|---|
| 301, 302 | Method becomes GET, body dropped |
| 307, 308 | Method and body preserved |
Cross-origin redirects (different scheme+host+port) automatically strip the Authorization header. Loop detection and max-hops enforcement prevent infinite chains.
Chunked Transfer Encoding
Section titled “Chunked Transfer Encoding”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 BodyReaderServer API
Section titled “Server API”Minimal Server (:core)
Section titled “Minimal Server (:core)”func handler(request: *ServerRequest, writer: *ResponseWriter) !void do writer.setStatus(200) .setHeader("Content-Type", "text/plain") .send("Hello from Janus!")end
// Blocking, sequential, single-threadedhttpServe(8080, handler, allocator)ResponseWriter
Section titled “ResponseWriter”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")endRouter
Section titled “Router”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_idendDispatch rules:
- No path match → 404 Not Found
- Path matches, wrong method → 405 Method Not Allowed (with
Allowheader) - First-match-wins ordering
Middleware
Section titled “Middleware”var chain = MiddlewareChain.init(allocator)defer chain.deinit()
// Global middlewarechain.use(logging_middleware)chain.use(cors_middleware)
// Path-scoped middlewarechain.usePrefix("/api/", auth_middleware)Concurrent Server (:service)
Section titled “Concurrent Server (:service)”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-limitedserver.serveAsync(router_handler)
// Graceful shutdown: drain in-flight, then force-closeserver.shutdown()Types Reference
Section titled “Types Reference”HttpMethod
Section titled “HttpMethod”GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONSCase-insensitive parsing via HttpMethod.fromString().
HttpStatus
Section titled “HttpStatus”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.
Headers
Section titled “Headers”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-insensitivelet all_accept = headers.getAll("Accept") // returns both valuesTotal header size tracked against 16 KiB limit (configurable). Non-ASCII rejected with HttpError.InvalidRequest.
Tagged union representing request/response bodies:
| Variant | Use Case |
|---|---|
Empty | HEAD responses, 204 No Content |
Fixed | Known-length body (Content-Length) |
Stream | Streaming via BodyReader |
Chunked | Chunked 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.
Error Handling
Section titled “Error Handling”All errors are values (per SPEC-032). No payloads – the variant name IS the diagnostic:
Connection Errors
Section titled “Connection Errors”| Error | Meaning |
|---|---|
ConnectionRefused | Target host rejected the connection |
ConnectionReset | Connection dropped mid-flight |
ConnectionClosed | Peer closed cleanly |
DnsResolutionFailed | Hostname could not be resolved |
Timeout | Deadline exceeded |
Cancelled | Request cancelled via Context |
Protocol Errors
Section titled “Protocol Errors”| Error | Meaning |
|---|---|
InvalidRequest | Malformed HTTP request |
InvalidResponse | Malformed HTTP response |
ChunkedEncodingError | Bad chunked frame |
ResponseTooLarge | Body exceeds max size (default 10 MiB) |
RequestTooLarge | Request body exceeds server limit |
TooManyRedirects | Redirect chain exceeded max hops |
Capability Errors
Section titled “Capability Errors”| Error | Meaning |
|---|---|
HostNotAllowed | Host not in capability allow-list |
SchemeNotAllowed | Scheme not in capability allow-list |
CapabilityDenied | Operation blocked by capability gate |
TLS Errors
Section titled “TLS Errors”| Error | Meaning |
|---|---|
TlsHandshakeFailed | TLS negotiation failed |
CertificateInvalid | Certificate validation failed |
CertificateExpired | Certificate past validity date |
Capability Gates (:sovereign)
Section titled “Capability Gates (:sovereign)”Client Capability
Section titled “Client Capability”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 MiBZero 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.
Server Capability
Section titled “Server Capability”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)Audit Trail
Section titled “Audit Trail”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 | .deniedRecords are stack-allocated – zero heap overhead per check. If no sink is provided, records are silently discarded.
Transport Trait
Section titled “Transport Trait”TLS is abstracted behind TlsTransport – a swappable trait:
// Current: stub backend (returns TlsHandshakeFailed)// Future: Zig std.crypto.tls backend// Future: janus.crypto.noise backendThe abstraction boundary is in place. When HTTPS is needed in production, a real backend slots in behind the trait without changing the API surface.
Per-Profile TLS Policy
Section titled “Per-Profile TLS Policy”| Profile | Plain HTTP | HTTPS |
|---|---|---|
:core | Allowed everywhere | Optional |
:service | Warning for non-localhost | Recommended |
:sovereign | Blocked for external hosts | Required |
:sovereign allows plain HTTP to localhost, 127.0.0.1, and ::1 for local development.
Architecture
Section titled “Architecture”Two-Layer Design
Section titled “Two-Layer Design”┌─────────────────────────────────────────────┐│ 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.
Module Layout (Panopticum)
Section titled “Module Layout (Panopticum)”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 hooksSBI Integration
Section titled “SBI Integration”std.net.http includes hooks for SBI (Sovereign Binary Interface) content negotiation:
Content-Type: application/x-sbidetectionX-SBI-Schemaheader for schema fingerprint validation- Accept header dispatch across
sbi,json, andplainformats
These are detection-only hooks – actual SBI encode/decode uses the std.sbi module.
Performance
Section titled “Performance”Targets
Section titled “Targets”| Mode | Target | Notes |
|---|---|---|
Blocking (:core) | 25,000 req/s | Single-threaded, no pool |
Async (:service) | 50,000+ req/s | Thread-per-connection, pooled |
Design Choices for Speed
Section titled “Design Choices for Speed”- 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
What’s Not Included
Section titled “What’s Not Included”Deliberate scope exclusions (Mechanism over Policy):
| Feature | Status | Rationale |
|---|---|---|
| HTTP/2 | Deferred | Complexity doesn’t justify teaching cost |
| WebSocket | Out of scope | Different protocol, different module |
| DNS caching | Out of scope | OS-level caching is sufficient |
| Retry logic | Out of scope | Mechanism over Policy – caller decides retry strategy |
| Cookie jar | Out of scope | Application-level concern |
| Compression | Deferred | gzip/deflate as future bridge extension |
Version History
Section titled “Version History”| Version | Change |
|---|---|
| v2026.3.13 | Initial release – complete client/server stack, 236 tests |