# "HTTP Client"
_Path: en/lua/http/client_
> "Make HTTP requests to external services. Supports all HTTP methods, headers, query parameters, form data, file uploads, streaming responses, and…"
## Table of Contents
- HTTP Client
## Content
# HTTP Client
The `http_client` module sends HTTP requests with headers, query parameters, forms, file uploads, authentication, TLS options, streaming responses, and concurrent batches.
This is an API reference with partial request recipes. URLs, tokens, credentials, request data, and certificate material come from the surrounding application. Examples check `Response, error` before consuming a response and close streamed bodies explicitly.
## Loading
```lua
local http_client = require("http_client")
```
Add `http_client` to the executable entry's `modules:` list before requiring it. JSON and filesystem recipes also require `json` and `fs`.
## HTTP Methods
Convenience methods use the `method(url, options?)` signature and return `Response, error`.
### GET
Send a `GET` request.
```lua
local resp, err = http_client.get("https://api.example.com/users")
if err then
return nil, err
end
print(resp.status_code) -- 200
print(resp.body) -- response body
```
### POST
Send a `POST` request.
```lua
local json = require("json")
local body, body_err = json.encode({name = "Alice", email = "alice@example.com"})
if body_err then return nil, body_err end
local resp, err = http_client.post("https://api.example.com/users", {
headers = {["Content-Type"] = "application/json"},
body = body
})
if err then return nil, err end
```
### PUT
Send a `PUT` request.
```lua
local body, body_err = json.encode({name = "Alice Smith"})
if body_err then return nil, body_err end
local resp, err = http_client.put("https://api.example.com/users/123", {
headers = {["Content-Type"] = "application/json"},
body = body
})
if err then return nil, err end
```
### PATCH
Send a `PATCH` request.
```lua
local body, body_err = json.encode({status = "active"})
if body_err then return nil, body_err end
local resp, err = http_client.patch("https://api.example.com/users/123", {
headers = {["Content-Type"] = "application/json"},
body = body
})
if err then return nil, err end
```
### DELETE
Send a `DELETE` request.
```lua
local resp, err = http_client.delete("https://api.example.com/users/123", {
headers = {["Authorization"] = "Bearer " .. token}
})
if err then return nil, err end
```
### HEAD
A `HEAD` request returns headers without a response body.
```lua
local resp, err = http_client.head("https://cdn.example.com/file.zip")
if err then return nil, err end
local size = resp.headers["Content-Length"]
```
### Custom Methods
Send a request using an explicit HTTP method string.
```lua
local resp, err = http_client.request("PROPFIND", "https://dav.example.com/folder", {
headers = {["Depth"] = "1"}
})
if err then return nil, err end
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `method` | string | HTTP method |
| `url` | string | Request URL |
| `options` | table | Request options (optional) |
## Request Options
| Field | Type | Description |
|-------|------|-------------|
| `headers` | table | Request headers `{["Name"] = "value"}` |
| `body` | string | Request body |
| `query` | table | Query parameters `{key = "value"}` |
| `form` | table | Form data (sets Content-Type automatically) |
| `files` | table | File uploads (array of file definitions) |
| `cookies` | table | Request cookies `{name = "value"}` |
| `auth` | table | Basic auth `{user = "name", pass = "secret"}` |
| `timeout` | number/string | Timeout: number in seconds, or string like `"30s"`, `"1m"` |
| `stream` | boolean | Stream response body instead of buffering |
| `max_response_body` | number | Max response size in bytes (0 = default) |
| `unix_socket` | string | Connect via Unix socket path |
| `tls` | table | Per-request TLS configuration (see [TLS Options](#tls-options)) |
| `overlay_network` | string | Route through a [network overlay](system/network.md) — registry ID of a `network.socks5` / `network.tailscale` / `network.i2p` entry |
Selecting `overlay_network` requires `network.select` permission on that network ID.
### Query Parameters
```lua
local resp, err = http_client.get("https://api.example.com/search", {
query = {
q = "lua programming",
page = "1",
limit = "20"
}
})
if err then return nil, err end
```
### Headers and Authentication
```lua
local resp, err = http_client.get("https://api.example.com/data", {
headers = {
["Authorization"] = "Bearer " .. token,
["Accept"] = "application/json"
}
})
if err then return nil, err end
-- Or use basic auth
local resp, err = http_client.get("https://api.example.com/data", {
auth = {user = service_user, pass = service_password}
})
if err then return nil, err end
```
Load authentication values from application-owned secret storage and send them only over TLS.
### Form Data
```lua
local resp, err = http_client.post("https://api.example.com/login", {
form = {
username = username,
password = password
}
})
if err then return nil, err end
```
### File Upload
```lua
local resp, err = http_client.post("https://api.example.com/upload", {
form = {title = "My Document"},
files = {
{
name = "attachment", -- form field name
filename = "report.pdf", -- original filename
content = pdf_data, -- file content
content_type = "application/pdf"
}
}
})
if err then return nil, err end
```
| File Field | Type | Required | Description |
|------------|------|----------|-------------|
| `name` | string | yes | Form field name |
| `filename` | string | no | Original filename |
| `content` | string | yes* | File content |
| `reader` | userdata | yes* | Alternative: io.Reader for content |
| `content_type` | string | no | Currently ignored: each uploaded part is always sent with `Content-Type: application/octet-stream` regardless of this field |
\* Either `content` or `reader` is required.
The pinned runtime fully reads a `reader` into memory before dispatch, does not close it, and does not surface a non-EOF read failure separately; it can send the bytes accumulated before that failure. Prefer `content` for already-bounded data, and close caller-owned readers after the request. The `content_type` field is parsed but not forwarded by runtime `v0.3.32a`, so uploaded parts use the transport default.
Reader-backed files are supported only by single-request calls in this release. `request_batch` forwards the `content` field but drops a parsed `reader`, so batch file uploads must provide `content`.
### Timeout
```lua
-- Number: seconds
local resp, err = http_client.get(url, {timeout = 30})
if err then return nil, err end
-- String alternatives use Go duration format: "30s", "1m30s", or "1h".
```
### TLS Options
Configure mutual TLS and custom CA certificates for one request.
| Field | Type | Description |
|-------|------|-------------|
| `cert` | string | Client certificate in PEM format |
| `key` | string | Client private key in PEM format |
| `ca` | string | Custom CA certificate in PEM format |
| `server_name` | string | Server name for SNI verification |
| `insecure_skip_verify` | boolean | Skip TLS certificate verification |
For mutual TLS, provide `cert` and `key` together. The `ca` field replaces the system certificate pool with a custom CA.
#### mTLS Authentication
```lua
local fs = require("fs")
local certs, volume_err = fs.get("app:certs")
if volume_err then return nil, volume_err end
local cert_pem, cert_err = certs:readfile("client.crt")
if cert_err then return nil, cert_err end
local key_pem, key_err = certs:readfile("client.key")
if key_err then return nil, key_err end
local resp, err = http_client.get("https://secure.example.com/api", {
tls = {
cert = cert_pem,
key = key_pem,
}
})
if err then return nil, err end
```
#### Custom CA
```lua
local fs = require("fs")
local certs, volume_err = fs.get("app:certs")
if volume_err then return nil, volume_err end
local ca_pem, ca_err = certs:readfile("internal-ca.crt")
if ca_err then return nil, ca_err end
local resp, err = http_client.get("https://internal.example.com/api", {
tls = {
ca = ca_pem,
server_name = "internal.example.com",
}
})
if err then return nil, err end
```
#### Insecure Skip Verify
`insecure_skip_verify` disables TLS verification and requires the `http_client.insecure_tls` security permission.
```lua
local resp, err = http_client.get("https://localhost:8443/api", {
tls = {
insecure_skip_verify = true,
}
})
if err then return nil, err end
```
Use `insecure_skip_verify` only for a controlled diagnostic endpoint. It disables both certificate-chain and hostname verification.
## Response Object
| Field | Type | Description |
|-------|------|-------------|
| `status_code` | number | HTTP status code |
| `body` | string | Response body (if not streaming) |
| `body_size` | number | Body size in bytes (-1 if streaming) |
| `headers` | table | Response headers |
| `cookies` | table | Response cookies |
| `url` | string | Final URL (after redirects) |
| `stream` | Stream | Stream object (if `stream = true`) |
```lua
local resp, err = http_client.get("https://api.example.com/data")
if err then
return nil, err
end
if resp.status_code == 200 then
local data, decode_err = json.decode(resp.body)
if decode_err then return nil, decode_err end
print("Content-Type:", resp.headers["Content-Type"])
end
```
## Streaming Responses
Set `stream = true` to process a response incrementally rather than buffering its full body.
```lua
local resp, err = http_client.get("https://cdn.example.com/large-file.zip", {
stream = true
})
if err then
return nil, err
end
-- Process in chunks
local read_err
while true do
local chunk
chunk, read_err = resp.stream:read(65536)
if read_err or not chunk then break end
-- process chunk
end
local _, close_err = resp.stream:close()
if read_err then return nil, read_err end
if close_err then return nil, close_err end
```
| Stream Method | Returns | Description |
|---------------|---------|-------------|
| `read(n?)` | string, error | Read up to `n` bytes (default: implementation buffer) |
| `close()` | boolean, error | Close the stream |
`resp.stream` is a full [stream](lua/core/stream.md) object — `seek`, `stat`, and `scanner` are also available. The caller owns a streamed response body and should close it on every exit; task cleanup is a fallback, not a substitute for prompt release.
## Batch Requests
`request_batch` executes multiple requests concurrently.
```lua
local requests = {
{"GET", "https://api.example.com/users"},
{"GET", "https://api.example.com/products"},
{"POST", "https://api.example.com/log", {body = "event"}}
}
local responses, batch_errors = http_client.request_batch(requests)
if not responses then
return nil, batch_errors -- whole-batch dispatch or validation failure
end
if batch_errors then
for i = 1, #requests do
local err = batch_errors[i]
if err then
print("Request " .. i .. " failed:", err)
end
end
else
-- All succeeded
for i, resp in ipairs(responses) do
print("Response " .. i .. ":", resp.status_code)
end
end
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `requests` | table | Array of `{method, url, options?}` |
**Returns:** `responses, errors` — arrays indexed by request position
**Notes:**
- Requests execute concurrently
- Streaming (`stream = true`) is not supported in batch
- Reader-backed file uploads are not supported in batch; use `files[].content`
- Result arrays match request order (1-indexed)
### Encode
Encode a string for inclusion in a URL.
```lua
local encoded = http_client.encode_uri("hello world")
-- "hello+world"
local url = "https://api.example.com/search?q=" .. http_client.encode_uri(query)
```
### Decode
Decode a string previously encoded with `http_client.encode_uri`.
```lua
local decoded, err = http_client.decode_uri("hello+world")
if err then return nil, err end
-- "hello world"
```
## Permissions
HTTP requests are evaluated against the active security policy.
### Security Actions
| Action | Resource | Description |
|--------|----------|-------------|
| `http_client.request` | URL | Allow/deny requests to specific URLs |
| `http_client.unix_socket` | Socket path | Allow/deny Unix socket connections |
| `http_client.private_ip` | IP address | Allow/deny access to private IP ranges |
| `http_client.insecure_tls` | URL | Allow/deny insecure TLS (skip verification) |
| `network.select` | Network entry ID | Allow/deny routing through the `overlay_network` given in the request |
### Checking Access
```lua
local security = require("security")
if security.can("http_client.request", "https://api.example.com/users") then
local resp, request_err = http_client.get("https://api.example.com/users")
if request_err then return nil, request_err end
end
```
### SSRF Protection
Non-public IP ranges are blocked by default. Access requires the `http_client.private_ip` permission on the address:
- loopback, private (10.x, 172.16-31.x, 192.168.x), link-local unicast and multicast, and the unspecified address
- carrier-grade NAT `100.64.0.0/10`, `192.0.0.0/24`, multicast `224.0.0.0/4`, reserved `240.0.0.0/4`
- documentation and benchmarking ranges `192.0.2.0/24`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32`
- IPv6 multicast `ff00::/8`
```lua
local resp, err = http_client.get("http://192.168.1.1/admin")
-- Error: not allowed: private IP 192.168.1.1
```
The check runs at dial time, not on the URL string, and it covers every address the host resolves to. A hostname that resolves to several addresses is checked address by address: a denied address is skipped and the next one is tried, and the request fails only when every candidate is denied or unreachable. A public hostname that resolves to a private address is therefore blocked exactly like a private IP literal.
### Redirects
Up to nine redirects are followed; the tenth fails with `stopped after 10 redirects`, a count that includes the original request.
Every hop is authorized on its own. Before following a redirect the client evaluates `http_client.request` against the target URL and applies the private-IP check to it, so a permitted URL cannot be used to reach a denied one by redirection. A hop that fails either check aborts the request.
See [Security Model](system/security.md) for policy configuration.
## Errors
| Condition | Kind | Retryable |
|-----------|------|-----------|
| Security policy denied | `errors.PERMISSION_DENIED` | no |
| Private IP blocked | `errors.PERMISSION_DENIED` | no |
| Unix socket denied | `errors.PERMISSION_DENIED` | no |
| Insecure TLS denied | `errors.PERMISSION_DENIED` | no |
| Invalid batch item, batch streaming, or invalid URI escape | `errors.INVALID` | no |
| No context | `errors.INTERNAL` | no |
| Malformed transport URL or network failure | `errors.INTERNAL` | yes |
| Timeout | `errors.INTERNAL` | yes |
Many unsupported option values are ignored rather than returned as structured errors. Invalid Lua argument types and an empty batch raise Lua argument errors. Validate application-supplied option tables before calling the client.
```lua
local resp, err = http_client.get(url)
if err then
if errors.is(err, errors.PERMISSION_DENIED) then
print("Access denied:", err:message())
elseif err:retryable() then
print("Temporary error:", err:message())
end
return nil, err
end
```
See [Error Handling](lua/core/errors.md) for working with errors.
## Navigation
Previous: "HTTP" (lua/http/http)
Next: "WebSocket Client" (lua/http/websocket)