# HTTP Client
_Path: en/lua/http/client_
## Table of Contents
- HTTP Client
## Content
# HTTP Client
Make HTTP requests to external services. Supports all HTTP methods, headers, query parameters, form data, file uploads, streaming responses, and concurrent batch requests.
## Loading
```lua
local http_client = require("http_client")
```
## HTTP Methods
All methods share the same signature: `method(url, options?)` returning `Response, error`.
### 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 Request
```lua
local resp, err = http_client.post("https://api.example.com/users", {
headers = {["Content-Type"] = "application/json"},
body = json.encode({name = "Alice", email = "alice@example.com"})
})
```
### PUT Request
```lua
local resp, err = http_client.put("https://api.example.com/users/123", {
headers = {["Content-Type"] = "application/json"},
body = json.encode({name = "Alice Smith"})
})
```
### PATCH Request
```lua
local resp, err = http_client.patch("https://api.example.com/users/123", {
body = json.encode({status = "active"})
})
```
### DELETE Request
```lua
local resp, err = http_client.delete("https://api.example.com/users/123", {
headers = {["Authorization"] = "Bearer " .. token}
})
```
### HEAD Request
Returns headers only, no body.
```lua
local resp, err = http_client.head("https://cdn.example.com/file.zip")
local size = resp.headers["Content-Length"]
```
### Custom Method
```lua
local resp, err = http_client.request("PROPFIND", "https://dav.example.com/folder", {
headers = {["Depth"] = "1"}
})
```
| 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)) |
### Query Parameters
```lua
local resp, err = http_client.get("https://api.example.com/search", {
query = {
q = "lua programming",
page = "1",
limit = "20"
}
})
```
### Headers and Authentication
```lua
local resp, err = http_client.get("https://api.example.com/data", {
headers = {
["Authorization"] = "Bearer " .. token,
["Accept"] = "application/json"
}
})
-- Or use basic auth
local resp, err = http_client.get("https://api.example.com/data", {
auth = {user = "admin", pass = "secret"}
})
```
### Form Data
```lua
local resp, err = http_client.post("https://api.example.com/login", {
form = {
username = "alice",
password = "secret123"
}
})
```
### 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"
}
}
})
```
| 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 | MIME type (default: `application/octet-stream`) |
*Either `content` or `reader` is required.
### Timeout
```lua
-- Number: seconds
local resp, err = http_client.get(url, {timeout = 30})
-- String: Go duration format
local resp, err = http_client.get(url, {timeout = "30s"})
local resp, err = http_client.get(url, {timeout = "1m30s"})
local resp, err = http_client.get(url, {timeout = "1h"})
```
### TLS Options
Configure per-request TLS settings for mTLS (mutual TLS) and custom CA certificates.
| 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 |
Both `cert` and `key` must be provided together for mTLS. The `ca` field overrides the system certificate pool with a custom CA.
#### mTLS Authentication
```lua
local cert_pem = fs.read("/certs/client.crt")
local key_pem = fs.read("/certs/client.key")
local resp, err = http_client.get("https://secure.example.com/api", {
tls = {
cert = cert_pem,
key = key_pem,
}
})
```
#### Custom CA
```lua
local ca_pem = fs.read("/certs/internal-ca.crt")
local resp, err = http_client.get("https://internal.example.com/api", {
tls = {
ca = ca_pem,
server_name = "internal.example.com",
}
})
```
#### Insecure Skip Verify
Skip TLS verification for development environments. Requires the `http_client.insecure_tls` security permission.
```lua
local resp, err = http_client.get("https://localhost:8443/api", {
tls = {
insecure_skip_verify = true,
}
})
```
## 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 = json.decode(resp.body)
print("Content-Type:", resp.headers["Content-Type"])
end
```
## Streaming Responses
For large responses, use streaming to avoid loading entire body into memory.
```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
while true do
local chunk, err = resp.stream:read(65536)
if err or not chunk then break end
-- process chunk
end
resp.stream:close()
```
| Stream Method | Returns | Description |
|---------------|---------|-------------|
| `read(size)` | string, error | Read up to `size` bytes |
| `close()` | - | Close the stream |
## Batch Requests
Execute multiple requests concurrently.
```lua
local responses, errors = http_client.request_batch({
{"GET", "https://api.example.com/users"},
{"GET", "https://api.example.com/products"},
{"POST", "https://api.example.com/log", {body = "event"}}
})
if errors then
for i, err in ipairs(errors) do
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
- Result arrays match request order (1-indexed)
### Encode
```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
```lua
local decoded, err = http_client.decode_uri("hello+world")
-- "hello world"
```
## Permissions
HTTP requests are subject to security policy evaluation.
### 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) |
### Checking Access
```lua
local security = require("security")
if security.can("http_client.request", "https://api.example.com/users") then
local resp = http_client.get("https://api.example.com/users")
end
```
### SSRF Protection
Private IP ranges (10.x, 192.168.x, 172.16-31.x, localhost) are blocked by default. Access requires the `http_client.private_ip` permission.
```lua
local resp, err = http_client.get("http://192.168.1.1/admin")
-- Error: not allowed: private IP 192.168.1.1
```
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 URL or options | `errors.INVALID` | no |
| No context | `errors.INTERNAL` | no |
| Network failure | `errors.INTERNAL` | yes |
| Timeout | `errors.INTERNAL` | yes |
```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 (lua/http/websocket)