# santoku-socket > HTTP(S) client: blocking fetch, cancelable request/await handle, and millisecond sleep over LuaSocket and LuaSec This file is one tab of the santoku reference, at https://santoku.dev/santoku-socket/llms.txt. https://santoku.dev/santoku-socket is the same tab in a browser, https://santoku.dev/llms.txt indexes every tab, and https://santoku.dev/llms-small.txt summarizes them all on one page. santoku-socket is the framework's native HTTP(S) client: a thin wrapper over LuaSocket and LuaSec (ssl.https plus ltn12) with a small, uniform request and response format. The surface is small: fetch for a blocking request, request for a cancelable handle whose await runs it, and a millisecond sleep built on santoku-system. Those same names are what santoku-http expects of a backend, so plugging this module into that front end adds retry, hooks, and query building with no glue, and identical client code runs over this module's browser and OpenResty siblings. It needs native TLS sockets, so it runs in servers, CLIs, and scripts rather than in this page: the examples are shown for reading, except the response model, which is plain Lua and runs live. The examples run in order: fetch, the response contract, failure paths, request and cancel, sleep, composition with santoku-http, the raw TLS stream driver underneath it all, the WebSocket client built on that driver, the TCP transport santoku-mysql runs over, and a loopback HTTP listener for browser callbacks. ### fetch: a simple GET A blocking GET returns ok (true for 2xx) and a response table; body is a function returning the full text. The whole ssl.https call sits inside a pcall, so fetch itself never raises on network trouble. ```lua local socket = require("santoku.socket") local ok, resp = socket.fetch("https://example.com/") print(ok, resp.status) print(resp.headers["content-type"]) return #resp.body() ``` ### The response fields Every completed response carries status, ok, headers, and body. Header keys are lowercased on the way in, and resp.ok always equals the first return. ```lua local socket = require("santoku.socket") local ok, resp = socket.fetch("https://example.com/") print(resp.status) print(resp.ok == ok) for k, v in pairs(resp.headers) do print(k, v) end return resp.body() ``` ### How a response is built, modeled live The same steps the module takes after ssl.https returns: concatenate the sink chunks into one body, lowercase every header key, compute ok from the 2xx range, and capture the body in a closure so it reads repeatedly. The construction is plain Lua, so this one runs here. ```lua local arr = require("santoku.array") local str = require("santoku.string") local chunks = { "{\"pong\"", ":true}" } local raw_headers = { ["Content-Type"] = "application/json", ["X-Request-Id"] = "abc123", } local body = arr.concat(chunks) local headers = {} for k, v in pairs(raw_headers) do headers[str.lower(k)] = v end local status = 200 local is2xx = status >= 200 and status < 300 local resp = { status = status, ok = is2xx, headers = headers, body = function () return body end, } print(resp.status, resp.ok) print(resp.headers["content-type"], resp.headers["x-request-id"]) print(resp.body()) print(resp.body() == resp.body()) return resp.body() ``` ### POST with a body Pass method, headers, and body in opts. When a body is present and no content-length header is given, one is derived from the body's byte length. ```lua local socket = require("santoku.socket") local ok, resp = socket.fetch("https://api.example.com/echo", { method = "POST", headers = { ["content-type"] = "application/json" }, body = "{\"hello\":\"world\"}", }) print(ok, resp.status) return resp.body() ``` ### Methods and headers Any method string passes straight through to the transport, and an explicit content-length is respected rather than overwritten. Auth is just another header. ```lua local socket = require("santoku.socket") local token = "..." local ok, resp = socket.fetch("https://api.example.com/items/42", { method = "PUT", headers = { ["authorization"] = "Bearer " .. token, ["content-type"] = "text/plain", ["content-length"] = "5", }, body = "hello", }) print(ok, resp.status) return resp.status ``` ### Non-2xx responses A request that completes with a non-2xx status returns false but still carries the real status, headers, and body, so error pages remain readable. ```lua local socket = require("santoku.socket") local ok, resp = socket.fetch("https://example.com/missing") print(ok, resp.ok, resp.status) return resp.body() ``` ### TLS verification An https fetch verifies the server certificate against the host's CA bundle (the first of santoku.socket.stream.ca_paths that exists, or opts.cafile and opts.capath) and checks the hostname against the certificate's names, the same path santoku.socket.stream uses for raw TLS connections. A failure in either check lands in resp.error with status 0 and no body. opts.verify = false skips both, and opts.sslname names a host to verify and send as SNI when it differs from the URL's. ```lua local socket = require("santoku.socket") local ok, resp = socket.fetch("https://self-signed.badssl.com/") print(ok, resp.status, resp.error) local ok2 = socket.fetch("https://self-signed.badssl.com/", { verify = false }) print(ok2) local ok3 = socket.fetch("https://10.0.0.5/", { cafile = "/etc/myca/root.pem", sslname = "api.internal", }) return ok3 ``` ### Transport failures Failures before any status arrives take one of two paths: a raised transport error is caught by the pcall and lands in resp.error, and a nil return from the transport puts its message there instead. Both yield status 0, empty headers, and a body() that returns nil rather than raising. ```lua local socket = require("santoku.socket") local ok, resp = socket.fetch("https://localhost:1/down") print(ok, resp.status) print(resp.error) return resp.body() == nil ``` ### Saving a response to disk body() hands back the whole text, so persisting a download is one santoku.fs call. ```lua local socket = require("santoku.socket") local fs = require("santoku.fs") local ok, resp = socket.fetch("https://example.com/data.json") if ok then fs.writefile("data.json", resp.body()) end print(ok, resp.status) return resp.headers["content-length"] ``` ### request: a cancelable handle request defers the work: nothing happens until await, which runs the fetch and returns the same ok, resp pair as fetch. ```lua local socket = require("santoku.socket") local req = socket.request("https://example.com/") local ok, resp = req.await() print(ok, resp.status) return resp.body() ``` ### cancel before and after the fetch Canceling before await short-circuits without issuing a request. await also re-checks the flag after the fetch returns, so a cancel raced in from a hook discards the response and yields the canceled sentinel. That sentinel is { status = 0, headers = {}, ok = false, canceled = true } with no body function, so check canceled before reading. ```lua local socket = require("santoku.socket") local job = socket.request("https://example.com/slow") job.cancel() local ok, resp = job.await() print(ok, resp.status, resp.canceled) return resp.canceled ``` ### sleep: milliseconds sleep takes milliseconds and delegates to santoku.system's second-based sleep, whose C side spends the whole seconds in sleep() and the remainder in usleep(), so fractional pauses like 250ms and 1.5s both work. ```lua local socket = require("santoku.socket") socket.sleep(250) socket.sleep(1500) return true ``` ### A REST wrapper in one function One function turns method, path, and params into a bearer-authenticated form-encoded request and returns a decoded JSON table or an error string. Because body() returns nil rather than raising on transport failure, the error path can use resp.error or the body without extra guards. ```lua local socket = require("santoku.socket") local str = require("santoku.string") local cjson = require("cjson") local key = "..." local function stripe (method, path, params) local opts = { method = method, headers = { ["authorization"] = "Bearer " .. key, ["content-type"] = "application/x-www-form-urlencoded", }, } if params then opts.body = str.to_formdata(params) end local ok, resp = socket.fetch("https://api.stripe.com" .. path, opts) local body = resp.body() if not ok then return nil, "request failed: " .. (resp.error or body or "unknown") end return cjson.decode(body) end local session, err = stripe("POST", "/v1/checkout/sessions", { mode = "subscription", }) return session or err ``` ### As the backend of santoku-http santoku-http expects a backend with fetch, request, and sleep, and prefers request when present, so cancellation flows through. One line adds query params via to_query, request and response hooks, and retry with jittered backoff: by default 3 retries starting at 1000ms with a 3x multiplier, triggered on status 0, 429, 502, 503, and 504. Retry and backoff belong there, so don't hand-roll them over fetch. To bound a whole retry sequence, cancel the request: santoku-http checks for cancellation after every backoff sleep. ```lua local http = require("santoku.http")(require("santoku.socket")) local ok, resp = http.get("https://api.example.com/search", { params = { q = "lua" }, }) print(ok, resp.status) local req = http.fetch("https://api.example.com/slow", { cancelable = true }) req.cancel() local ok2, r2 = req.await() print(ok2, r2.canceled) return r2.canceled ``` ### One contract, three transports Because the response fields are shared, the same application code runs over three interchangeable backends: this module in native scripts, santoku.web.socket in the browser, and santoku.resty.socket under OpenResty. ```lua local native = require("santoku.http")(require("santoku.socket")) local browser = require("santoku.http")(require("santoku.web.socket")) local server = require("santoku.http")(require("santoku.resty.socket")) local function ping (http) local ok, resp = http.get("https://example.com/ping") return ok and resp.body() end return ping ``` ### santoku.socket.stream: the raw TLS stream driver Protocols that are not request and response need bytes rather than messages. santoku.socket.stream is that layer: connect(opts, done) dials host and port, wraps the socket in TLS unless tls = false, and hands done(ok, conn) a connection with write, close and step. Inbound data is pushed, not pulled: every chunk the socket produces goes to the data callback in whatever fragmentation the network gave it, and reassembly is the protocol's job. Because LuaSocket blocks and has no event loop, this driver also exposes step(ms), one bounded read delivered through the same data callback; it returns true, "timeout" when the window passed with nothing to read, and false plus a reason once the connection is gone. connect_timeout_ms defaults to 30000, and sslname overrides the SNI name when it differs from host. ```lua local stream = require("santoku.socket.stream") stream.connect({ host = "imap.example.com", port = 993, data = function (chunk) print("read", #chunk, "bytes") end, closed = function (e) print("closed:", e) end, }, function (ok, conn) if not ok then return print("connect failed:", conn) end print("verified:", conn.tls.verified) conn.write("A1 CAPABILITY\r\n") for _ = 1, 10 do conn.step(200) end conn.close() end) return true ``` ### santoku.socket.websocket: a WebSocket client connect(url, opts, done) opens a ws:// or wss:// connection over the stream driver, runs the RFC 6455 handshake and hands done(ok, conn) the connection or a reason. Messages are pushed: opts.message(data, kind) receives each reassembled message with kind "text" or "binary", and opts.ping, opts.pong and opts.closed(code, reason) report control frames. conn.send(data[, binary]) sends text unless binary is true and refuses text that is not valid UTF-8; conn.ping(data), conn.close([code, reason, ms]) (1000 and a one-second wait by default), conn.step(ms) for one bounded read, and conn.wait(fn, ms) steps until fn() is true or the time runs out, returning false, "timeout". Nothing reads a conn's socket except its own step and wait, so with two clients in one process, a message for the second waits in its socket until you call wait or step on that conn. Other options: connect_timeout_ms (30000), max_message (64 MiB), frame_max to fragment large sends, protocols, headers, and verify, cafile, capath and sslname as for the stream driver. It has driven Chrome's DevTools protocol end to end, a 299 KB screenshot message included. ```lua local ws = require("santoku.socket.websocket") local reply ws.connect("wss://echo.example.com/socket", { message = function (data, kind) reply = data end, closed = function (code, reason) print("closed:", code, reason) end, }, function (ok, conn) if not ok then return print("connect failed:", conn) end conn.send("hello") print(conn.wait(function () return reply ~= nil end, 5000), reply) conn.close() end) return true ``` ### santoku.socket.tcp: a byte transport for santoku-mysql connect(opts) opens a TCP connection to opts.host and opts.port, or a unix socket at opts.path, and returns a transport, or nil and the error. opts.timeout_ms (30000) bounds every connect, send and receive. send(data) writes all of data and returns true, or nil and the error. receive(n) blocks until it has n bytes. starttls(opts) upgrades the connection in place with santoku.socket.stream's verification (verify, cafile, capath, sslname) and sets t.tls. idle() returns true when no unread bytes wait on the socket, and close() closes it. santoku-mysql takes this module as its native transport, and santoku-resty's santoku.resty.tcp is the same contract over cosockets. ```lua local tcp = require("santoku.socket.tcp") local err = require("santoku.error") local t = err.assert(tcp.connect({ host = "127.0.0.1", port = 6379, timeout_ms = 2000 })) err.assert(t.send("PING\r\n")) print(t.receive(7) == "+PONG\r\n", t.idle()) t.close() print(tcp.connect({ host = "127.0.0.1", port = 1, timeout_ms = 500 })) ``` ### santoku.socket.server: a loopback HTTP listener listen(opts) binds opts.host (127.0.0.1) on opts.port, 0 by default for an ephemeral port, and returns a server with host, port, url, serve and close. It suits a CLI that needs a browser to call it back, such as an OAuth redirect or a page that posts a token. serve(handler, { timeout = ms }) accepts one connection at a time and calls handler(req) with method, path, target, query (decoded), headers (lowercased) and body (read to Content-Length, at most opts.max_body bytes, 1 MiB by default). The handler returns a response table { status, headers, body } and, to stop, a second value; serve then returns true and that value. At the timeout serve returns false and "timeout". Every response closes its connection. A malformed request gets 400, an oversized body 413, and a slow client 408, without reaching the handler. A handler error answers 500 and raises. No TLS, no keepalive and no chunked bodies. ```lua local server = require("santoku.socket.server") local srv = server.listen() print("open", srv.url) local ok, token = srv.serve(function (req) if req.method == "GET" and req.path == "/" then return { status = 200, headers = { ["Content-Type"] = "text/html" }, body = "
" } elseif req.method == "POST" and req.path == "/done" then return { status = 200, body = "You can close this tab." }, req.body end return { status = 404 } end, { timeout = 600000 }) srv.close() print(ok, token) ``` ### Verification is on, and the hostname is checked here luasec verifies the certificate chain and nothing else, so this driver does the rest. It takes the CA bundle from SSL_CERT_FILE, or the directory from SSL_CERT_DIR, then probes the common Linux and macOS paths, and last uses the bundle or directory OpenSSL was built to look in. It verifies the chain against it and matches the target name against the certificate's subjectAltName dNSName entries, honouring a single leading wildcard label. A certificate with no subjectAltName fails, since RFC 6125 retires commonName matching. A mismatch closes the connection and reports which names the certificate actually carries. No bundle found is a hard error rather than a silent downgrade: set SSL_CERT_FILE, pass cafile or capath, or verify = false to opt out deliberately. conn.tls reports the outcome, carrying verified, the bundle used, and the names matched against. stream.ca_paths is the probe list and can be extended before connecting. ```lua local stream = require("santoku.socket.stream") stream.connect({ host = "imap.example.com", port = 993, cafile = "/etc/ssl/certs/ca-certificates.crt", data = function (chunk) print(#chunk) end, }, function (ok, conn) print(ok, ok and conn.tls.verified, ok and conn.tls.names[1]) end) stream.connect({ host = "192.0.2.10", port = 993, verify = false, data = function () end, }, function (ok, conn) print("unverified:", ok, conn.tls.verified) end) return true ``` ### One driver contract, every runtime The driver is injected, so a protocol written against this contract runs unchanged wherever a driver exists: santoku.socket.stream over LuaSocket and LuaSec, santoku.web.stream over node tls under wasm, and santoku.resty.stream over ngx cosockets. santoku-imap is the consumer to read: it takes a driver and detects whether step is present, pumping internally on a pull driver so that completion is synchronous there and event-driven on a push driver, with the same callback API either way. ```lua local imap = require("santoku.imap")(require("santoku.socket.stream")) imap.connect({ host = "imap.example.com", port = 993 }, function (ok, client) if not ok then return print("connect failed:", client) end client.login("me@example.com", "app-password", function (okl) print("login:", okl) client.logout(function () end) end) end) return true ```