--- The HTTP client, built on LuaSocket (and LuaSec for `https://`). -- -- Each request opens a fresh TCP+TLS connection; nothing is pooled. For a -- one-off lookup that is irrelevant. For thousands, **do not write a loop** — -- use `Client:lookup_many`, which resolves up to 500 CEPs per request. A loop -- of 500 calls pays 500 TLS handshakes; the batch pays one. -- -- Retry policy: 429 and the transient 5xx, plus network failures, are retried -- with exponentially backed-off, jittered waits. 400 and 404 are deliberately -- absent from that set — both are definitive answers about the request, so -- retrying returns the same thing more slowly while burning someone's quota. -- -- @module cepis.client local socket = require("socket") local http = require("socket.http") local ltn12 = require("ltn12") local json = require("dkjson") local errors = require("cepis.errors") local models = require("cepis.models") local csv = require("cepis.csv") local M = {} --- Library version, as it appears in the default `User-Agent`. M.VERSION = "0.1.0" --- Where the API lives unless `base_url` says otherwise. M.DEFAULT_URL = "https://cep.is" --- How many CEPs the batch endpoint accepts in one request. M.MAX_BATCH = 500 local ACCEPT = "application/json, text/csv;q=0.9, */*;q=0.1" local RETRIABLE_STATUS = { [429] = true, [500] = true, [502] = true, [503] = true, [504] = true, } local MONTHS = { Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6, Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12, } local ARRAY_MT = {} if _VERSION < "Lua 5.4" then math.randomseed(os.time()) end local function quote(value) local text = tostring(value) if #text > 60 then text = text:sub(1, 60) .. "..." end return "'" .. (text:gsub("%c", " ")) .. "'" end local function urlescape(value) return (value:gsub("[^%w%-%.%_%~]", function(char) return string.format("%%%02X", string.byte(char)) end)) end local function utc_to_epoch(fields) fields.isdst = false local as_local = os.time(fields) if not as_local then return nil end local utc = os.date("!*t", as_local) utc.isdst = false return as_local + os.difftime(as_local, os.time(utc)) end --- Seconds to wait, from a `Retry-After` header value. -- -- RFC 9110 allows two forms: a number of seconds (`"120"`) and an HTTP date -- (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common -- bug, and it makes a client ignore exactly the wait request that matters -- most. -- -- @tparam string|nil value the raw header -- @treturn number|nil seconds, never negative, or `nil` if unparseable function M.retry_after_seconds(value) if type(value) ~= "string" then return nil end value = value:match("^%s*(.-)%s*$") if value == "" then return nil end local seconds = tonumber(value) if seconds then return math.max(0, seconds) end local day, month, year, hour, minute, second = value:match("^%a+,%s+(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+GMT$") if not day then day, month, year, hour, minute, second = value:match("^(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+GMT$") end if not day or not MONTHS[month] then return nil end local target = utc_to_epoch({ year = tonumber(year), month = MONTHS[month], day = tonumber(day), hour = tonumber(hour), min = tonumber(minute), sec = tonumber(second), }) if not target then return nil end return math.max(0, target - os.time()) end --- Return the CEP's 8 digits, accepting the mask. -- -- `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. -- -- Returns `nil, err` with `err.kind == "invalid_cep"` unless exactly 8 digits -- remain. This runs **before** any request: a 7-digit CEP is the caller's bug, -- and spending a round trip for the server to say so wastes both sides' time. -- -- @tparam string value masked or unmasked -- @treturn[1] string the 8 digits -- @treturn[2] nil -- @treturn[2] table the error function M.normalize_cep(value) if type(value) ~= "string" then return nil, errors.new(errors.INVALID_CEP, "CEP must be a string, got " .. type(value)) end local cleaned = value:gsub("[%s%.%-]", "") if not cleaned:match("^%d%d%d%d%d%d%d%d$") then return nil, errors.new( errors.INVALID_CEP, "CEP must be 8 digits, got " .. quote(value) ) end return cleaned end local function decode_object(body) local data = json.decode(body, 1, nil, nil, ARRAY_MT) if type(data) ~= "table" or getmetatable(data) == ARRAY_MT then return nil, errors.new( errors.INVALID_RESPONSE, "response is not a JSON object — something between you and the API is " .. "answering instead of it: " .. quote(body) ) end return data end local function error_message(body, fallback) local ok, data = pcall(json.decode, body or "", 1, nil) if ok and type(data) == "table" and type(data.mensagem) == "string" then if data.mensagem ~= "" then return data.mensagem end end return fallback end local function transport(url) if url:lower():find("^https://") then local ok, https = pcall(require, "ssl.https") if not ok then return nil, "an https:// base_url needs LuaSec (luarocks install luasec); " .. "use an http:// base_url to avoid it" end return https.request end return http.request end --- Client for the cep.is API. -- -- Reuse one instance: the configuration is not mutated and no method keeps -- state between calls. -- -- @type Client -- @field base_url string -- @field timeout number -- @field max_attempts number -- @field backoff_base number -- @field backoff_max number -- @field total_timeout number|nil -- @field user_agent string local Client = {} Client.__index = Client Client.__name = "cepis.Client" M.Client = Client --- Build a client. -- -- Every option has a usable default, so `Client.new()` is valid. A bad option -- raises through `error()` rather than returning one: it is a mistake in the -- calling code, not a condition to handle at runtime. -- -- @tparam[opt] table opts -- @tparam[opt="https://cep.is"] string opts.base_url -- Where the API lives. Point it at a local instance (the API repo brings one -- up on `http://localhost:8080` with `just up`) or at a test server. It is -- the only option that usually changes between environments. Trailing -- slashes are stripped, so it cannot produce doubled separators that some -- proxies normalise and others 404. -- @tparam[opt=10] number opts.timeout -- Seconds to wait **per socket operation** (connect, and each read) — this -- is not a cap on the whole call. That cap is `total_timeout`. -- @tparam[opt=3] number opts.max_attempts -- How many attempts in total, counting the first. `1` disables retrying. The -- default of `3` covers the common case (one transient failure) without -- turning a real outage into a long wait. -- @tparam[opt=0.5] number opts.backoff_base -- Base of the exponential wait, in seconds. The actual wait is drawn from -- `[0, base * 2^(n-1)]` — *full jitter*. The randomness is not decoration: -- without it, N clients that fail together come back together and reproduce -- the spike that took the service down. -- @tparam[opt=8] number opts.backoff_max -- Cap on each individual wait, in seconds. It applies to a server-sent -- `Retry-After` too, or a `Retry-After: 3600` from a misconfigured proxy -- would park the caller for an hour. -- @tparam[opt] number opts.total_timeout -- Cap on the whole call, attempts and waits included. `nil` leaves -- `max_attempts` as the only bound. Set it in code serving a web request: -- without it the worst case is `max_attempts * timeout` plus the sum of the -- backoffs. -- @tparam[opt] string opts.user_agent -- Overrides the `User-Agent`. The default identifies the library and its -- version, which is what lets the server diagnose an old client -- misbehaving. -- @treturn Client function Client.new(opts) opts = opts or {} local base_url = opts.base_url or M.DEFAULT_URL local timeout = opts.timeout or 10.0 local max_attempts = opts.max_attempts or 3 local backoff_base = opts.backoff_base or 0.5 local backoff_max = opts.backoff_max or 8.0 local total_timeout = opts.total_timeout if type(base_url) ~= "string" or base_url == "" then error("base_url must be a non-empty string", 2) end if type(timeout) ~= "number" or timeout <= 0 then error("timeout must be a number > 0", 2) end if type(max_attempts) ~= "number" or max_attempts < 1 then error("max_attempts must be a number >= 1", 2) end if type(backoff_base) ~= "number" or backoff_base < 0 then error("backoff_base must be a number >= 0", 2) end if type(backoff_max) ~= "number" or backoff_max < 0 then error("backoff_max must be a number >= 0", 2) end if total_timeout ~= nil and (type(total_timeout) ~= "number" or total_timeout <= 0) then error("total_timeout must be a number > 0, or nil", 2) end if opts.user_agent ~= nil and type(opts.user_agent) ~= "string" then error("user_agent must be a string", 2) end return setmetatable({ base_url = (base_url:gsub("/+$", "")), timeout = timeout, max_attempts = math.floor(max_attempts), backoff_base = backoff_base, backoff_max = backoff_max, total_timeout = total_timeout, user_agent = opts.user_agent or ("cepis-lua/" .. M.VERSION .. " (+https://cep.is)"), }, Client) end function Client:_perform(url, request) local chunks = {} local previous = http.TIMEOUT http.TIMEOUT = self.timeout local called, ok, code, headers = pcall(request, { url = url, method = "GET", headers = { ["user-agent"] = self.user_agent, ["accept"] = ACCEPT, ["accept-encoding"] = "identity", }, sink = ltn12.sink.table(chunks), redirect = false, }) http.TIMEOUT = previous if not called then return nil, tostring(ok) end if not ok or type(code) ~= "number" then return nil, tostring(code) end return code, table.concat(chunks), headers or {} end function Client:_backoff(attempt, retry_after) if retry_after then return math.min(retry_after, self.backoff_max) end local ceiling = math.min(self.backoff_base * 2 ^ (attempt - 1), self.backoff_max) return math.random() * ceiling end function Client:_request(path, accepted) accepted = accepted or { [200] = true } local url = self.base_url .. path local request, unavailable = transport(url) if not request then return nil, errors.new(errors.NETWORK_ERROR, "cannot reach " .. url .. ": " .. unavailable) end local deadline = nil if self.total_timeout then deadline = socket.gettime() + self.total_timeout end local last_status, last_retry_after, last_network for attempt = 1, self.max_attempts do if deadline and socket.gettime() >= deadline then break end local status, body, headers = self:_perform(url, request) if status then if accepted[status] or not RETRIABLE_STATUS[status] then return status, body, headers end last_status = status last_retry_after = M.retry_after_seconds(headers["retry-after"]) last_network = nil else last_network = body last_status = nil last_retry_after = nil end if attempt == self.max_attempts then break end local wait = self:_backoff(attempt, last_retry_after) if deadline then local remaining = deadline - socket.gettime() if remaining <= 0 then break end if wait > remaining then wait = remaining end end socket.sleep(wait) end local suffix = string.format(" (after %d attempt(s))", self.max_attempts) if last_status == 429 then return nil, errors.new(errors.RATE_LIMITED, "rate limit reached at " .. url .. suffix, { status = 429, retry_after = last_retry_after, }) end if last_status then return nil, errors.new( errors.SERVER_ERROR, string.format("the API answered %d at %s%s", last_status, url, suffix), { status = last_status } ) end return nil, errors.new( errors.NETWORK_ERROR, "could not reach " .. url .. suffix .. ": " .. tostring(last_network) ) end --- Look up a single CEP. -- -- local address, err = client:lookup("01001-000") -- print(address.localidade, address.uf) --> São Paulo SP -- -- A 400 from the API surfaces as `invalid_cep` carrying the API's own message: -- the format already passed local validation, so the server knows something -- this client does not. -- -- @tparam string cep masked or unmasked -- @treturn[1] Address -- @treturn[2] nil -- @treturn[2] table the error; `kind` is one of `invalid_cep` (not 8 digits — -- no request is made), `not_found` (well-formed, absent from the database), -- `rate_limited`, `server_error`, `network_error`, `invalid_response` function Client:lookup(cep) local number, invalid = M.normalize_cep(cep) if not number then return nil, invalid end local status, body = self:_request("/" .. number) if not status then return nil, body end if status == 404 then return nil, errors.new( errors.NOT_FOUND, "CEP " .. number .. " does not exist in the DNE database", { cep = number, status = 404 } ) end if status == 400 then return nil, errors.new( errors.INVALID_CEP, error_message(body, "CEP " .. number .. " rejected by the API"), { cep = number, status = 400 } ) end local data, malformed = decode_object(body) if not data then return nil, malformed end return models.new_address(data) end function Client:_lookup_chunk(ceps) local wanted = #ceps local query = "ceps=" .. urlescape(table.concat(ceps, ",")) local status, body = self:_request("/csv?" .. query) if not status then return nil, body end local rows = csv.parse(body) local out = {} local count = 0 if #rows > 0 then local header = rows[1] for index = 2, #rows do if count >= wanted then break end local record = {} for column = 1, #header do record[header[column]] = rows[index][column] or "" end count = count + 1 if record.localidade == nil or record.localidade:match("^%s*$") then out[count] = false else out[count] = models.new_address(record) end end end for index = count + 1, wanted do out[index] = false end return out end --- Look up many CEPs in one batch. -- -- Returns a list of **the same length and in the same order** as the input. -- -- **A CEP that does not exist is `false` at its position, not `nil`.** This is -- the one place where Lua forces a difference from the other clients in this -- family, and it is not cosmetic: a `nil` inside a sequence truncates it at the -- first hole, so `#result` and `ipairs` would silently stop at the first -- missing CEP and drop every address after it. `false` keeps the sequence -- dense, and `if address then` still reads as the natural test: -- -- local result = client:lookup_many({ "01001000", "99999999", "20040020" }) -- for index, address in ipairs(result) do -- if address then -- print(ceps[index], address.localidade) -- else -- print(ceps[index], "not found") -- end -- end -- -- A malformed CEP is `false` too, for the same reason, rather than failing the -- call: in a batch of a thousand, one bad CEP must not cost the other nine -- hundred and ninety-nine. If you need to know *which* one was malformed, -- validate first with `normalize_cep`. -- -- Input is chunked into blocks of 500 (the API's limit) automatically. -- -- **The batch carries no coordinate.** The endpoint behind it is CSV, and the -- CSV has no `geo` column. Every `Address` returned here has `geo == nil`, -- including for CEPs that do have one. Need `geo`? Use `lookup`, one at a -- time. -- -- @tparam table ceps list of strings -- @treturn[1] table list of `Address|false`, same length and order as `ceps` -- @treturn[2] nil -- @treturn[2] table the error, if the batch request itself failed function Client:lookup_many(ceps) if type(ceps) ~= "table" then error("lookup_many expects a table of CEPs, got " .. type(ceps), 2) end local total = #ceps if total == 0 then return {} end local result = {} local filled = 0 local start = 1 while start <= total do local stop = math.min(start + M.MAX_BATCH - 1, total) local chunk = {} for index = start, stop do chunk[#chunk + 1] = tostring(ceps[index]) end local rows, err = self:_lookup_chunk(chunk) if not rows then return nil, err end for index = 1, #chunk do filled = filled + 1 result[filled] = rows[index] end start = stop + 1 end return result end --- Query `GET /health`: does the API answer, and with which database. -- -- An API reporting itself degraded is not an error — that arrives in -- `health.status`, and checking health is exactly when you want the diagnosis -- rather than a failure. Network failures still return `nil, err`. -- -- @treturn[1] Health -- @treturn[2] nil -- @treturn[2] table the error function Client:health() local status, body = self:_request("/health", { [200] = true, [503] = true }) if not status then return nil, body end local data, malformed = decode_object(body) if not data then return nil, malformed end return models.new_health(data) end return M