summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/cepis.rs100
-rw-r--r--src/client.rs559
-rw-r--r--src/csv.rs99
-rw-r--r--src/error.rs150
-rw-r--r--src/lib.rs100
-rw-r--r--src/model.rs227
-rw-r--r--src/retry.rs143
7 files changed, 1378 insertions, 0 deletions
diff --git a/src/bin/cepis.rs b/src/bin/cepis.rs
new file mode 100644
index 0000000..252cb90
--- /dev/null
+++ b/src/bin/cepis.rs
@@ -0,0 +1,100 @@
+//! Minimal CLI: `cepis 01001000`.
+//!
+//! It exists for one concrete case — quickly confirming a local instance
+//! answers before pointing code at it:
+//!
+//! ```sh
+//! cepis --base-url http://localhost:8080 01001000
+//! ```
+//!
+//! A single CEP goes through the unit lookup, which carries `geo`; several go
+//! through the batch, which is one request instead of N and has no `geo`.
+//!
+//! It is not the library's main interface; for that, use the `cepis` crate.
+
+use std::process::ExitCode;
+use std::time::Duration;
+
+use cepis::{Client, DEFAULT_URL};
+use serde_json::Value;
+
+const USAGE: &str = "\
+usage: cepis [--base-url URL] [--timeout SECONDS] CEP [CEP ...]
+
+Look CEPs up against the cep.is API.
+
+ --base-url URL API URL (default: https://cep.is) — point it at a local instance
+ --timeout SECONDS per-socket timeout (default: 10)
+ -h, --help show this message";
+
+fn main() -> ExitCode {
+ let mut base_url = DEFAULT_URL.to_owned();
+ let mut timeout = Duration::from_secs(10);
+ let mut ceps: Vec<String> = Vec::new();
+ let mut args = std::env::args().skip(1);
+
+ while let Some(arg) = args.next() {
+ match arg.as_str() {
+ "-h" | "--help" => {
+ println!("{USAGE}");
+ return ExitCode::SUCCESS;
+ }
+ "--base-url" => match args.next() {
+ Some(value) => base_url = value,
+ None => return fail("--base-url needs a URL"),
+ },
+ "--timeout" => match args.next().map(|v| v.parse::<f64>()) {
+ Some(Ok(seconds)) if seconds > 0.0 => timeout = Duration::from_secs_f64(seconds),
+ _ => return fail("--timeout needs a positive number of seconds"),
+ },
+ other if other.starts_with('-') => return fail(&format!("unknown option {other}")),
+ other => ceps.push(other.to_owned()),
+ }
+ }
+
+ if ceps.is_empty() {
+ eprintln!("{USAGE}");
+ return ExitCode::FAILURE;
+ }
+
+ let client = Client::builder()
+ .base_url(base_url)
+ .timeout(timeout)
+ .build();
+
+ let addresses = if ceps.len() == 1 {
+ match client.lookup(&ceps[0]) {
+ Ok(address) => vec![Some(address)],
+ Err(err) => return fail(&err.to_string()),
+ }
+ } else {
+ match client.lookup_many(&ceps) {
+ Ok(found) => found,
+ Err(err) => return fail(&err.to_string()),
+ }
+ };
+
+ let output: Vec<Value> = addresses
+ .into_iter()
+ .map(|a| a.map(|a| a.raw).unwrap_or(Value::Null))
+ .collect();
+
+ let rendered = if output.len() == 1 {
+ serde_json::to_string_pretty(&output[0])
+ } else {
+ serde_json::to_string_pretty(&output)
+ };
+
+ match rendered {
+ Ok(text) => {
+ println!("{text}");
+ ExitCode::SUCCESS
+ }
+ Err(err) => fail(&err.to_string()),
+ }
+}
+
+fn fail(message: &str) -> ExitCode {
+ eprintln!("error: {message}");
+ ExitCode::FAILURE
+}
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..8d73f06
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,559 @@
+//! The HTTP client.
+//!
+//! Retry policy: 429 and the transient 5xx in [`RETRIABLE_STATUS`], 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 else's quota.
+
+use std::time::{Duration, Instant};
+
+use serde_json::{Map, Value};
+
+use crate::csv;
+use crate::error::{Error, Result};
+use crate::model::{Address, Health};
+use crate::retry::{jitter, parse_retry_after};
+
+/// Where the public API lives.
+pub const DEFAULT_URL: &str = "https://cep.is";
+
+/// How many CEPs the batch endpoint accepts in one request.
+pub const MAX_BATCH: usize = 500;
+
+/// The statuses worth trying again: rate limiting and the transient 5xx.
+pub const RETRIABLE_STATUS: [u16; 5] = [429, 500, 502, 503, 504];
+
+const ACCEPT: &str = "application/json, text/csv;q=0.9, */*;q=0.1";
+
+/// Return the CEP's 8 digits, accepting the mask.
+///
+/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+///
+/// Fails with [`Error::InvalidCep`] 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.
+///
+/// ```
+/// assert_eq!(cepis::normalize_cep("01001-000").unwrap(), "01001000");
+/// assert!(cepis::normalize_cep("123").is_err());
+/// ```
+pub fn normalize_cep(value: &str) -> Result<String> {
+ let cleaned: String = value
+ .trim()
+ .chars()
+ .filter(|c| !matches!(c, '-' | '.' | ' '))
+ .collect();
+
+ if cleaned.len() == 8 && cleaned.bytes().all(|b| b.is_ascii_digit()) {
+ Ok(cleaned)
+ } else {
+ Err(Error::invalid_cep(format!(
+ "CEP must be 8 digits, got {value:?}"
+ )))
+ }
+}
+
+/// Builder for a [`Client`].
+///
+/// Every value has a default; set only what differs. See [`Client`] for what
+/// each one means.
+#[derive(Debug, Clone)]
+pub struct ClientBuilder {
+ base_url: String,
+ timeout: Duration,
+ max_attempts: u32,
+ backoff_base: Duration,
+ backoff_max: Duration,
+ total_timeout: Option<Duration>,
+ user_agent: String,
+}
+
+impl Default for ClientBuilder {
+ fn default() -> Self {
+ ClientBuilder {
+ base_url: DEFAULT_URL.to_owned(),
+ timeout: Duration::from_secs(10),
+ max_attempts: 3,
+ backoff_base: Duration::from_millis(500),
+ backoff_max: Duration::from_secs(8),
+ total_timeout: None,
+ user_agent: format!("cepis-rust/{} (+https://cep.is)", crate::VERSION),
+ }
+ }
+}
+
+impl ClientBuilder {
+ /// 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 setting that usually changes between environments. A trailing slash
+ /// is stripped, so it cannot produce doubled separators that some proxies
+ /// normalise and others 404.
+ pub fn base_url(mut self, value: impl Into<String>) -> Self {
+ self.base_url = value.into();
+ self
+ }
+
+ /// How long to wait **per socket operation** (connect, and each read).
+ ///
+ /// This is not a cap on the whole call — that cap is
+ /// [`total_timeout`](ClientBuilder::total_timeout).
+ pub fn timeout(mut self, value: Duration) -> Self {
+ self.timeout = value;
+ self
+ }
+
+ /// 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.
+ pub fn max_attempts(mut self, value: u32) -> Self {
+ self.max_attempts = value;
+ self
+ }
+
+ /// Base of the exponential wait.
+ ///
+ /// 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.
+ pub fn backoff_base(mut self, value: Duration) -> Self {
+ self.backoff_base = value;
+ self
+ }
+
+ /// Cap on each individual wait.
+ ///
+ /// It applies to a server-sent `Retry-After` too, or a `Retry-After: 3600`
+ /// from a misconfigured proxy would park the thread for an hour.
+ pub fn backoff_max(mut self, value: Duration) -> Self {
+ self.backoff_max = value;
+ self
+ }
+
+ /// Cap on the whole call, attempts and waits included.
+ ///
+ /// `None` leaves [`max_attempts`](ClientBuilder::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.
+ pub fn total_timeout(mut self, value: Option<Duration>) -> Self {
+ self.total_timeout = value;
+ self
+ }
+
+ /// Override the `User-Agent`.
+ ///
+ /// The default identifies the library and its version, which is what lets
+ /// the server diagnose an old client misbehaving.
+ pub fn user_agent(mut self, value: impl Into<String>) -> Self {
+ self.user_agent = value.into();
+ self
+ }
+
+ /// Build the client.
+ ///
+ /// # Panics
+ ///
+ /// If `max_attempts` is 0 or `timeout` is zero — both are configuration
+ /// mistakes that can only produce a client which never completes a call.
+ pub fn build(self) -> Client {
+ assert!(self.max_attempts >= 1, "max_attempts must be >= 1");
+ assert!(!self.timeout.is_zero(), "timeout must be > 0");
+
+ let config = ureq::Agent::config_builder()
+ .http_status_as_error(false)
+ .user_agent(self.user_agent.as_str())
+ .accept(ACCEPT)
+ .accept_encoding("identity")
+ .timeout_connect(Some(self.timeout))
+ .timeout_send_request(Some(self.timeout))
+ .timeout_recv_response(Some(self.timeout))
+ .timeout_recv_body(Some(self.timeout))
+ .build();
+
+ Client {
+ base_url: self.base_url.trim_end_matches('/').to_owned(),
+ timeout: self.timeout,
+ max_attempts: self.max_attempts,
+ backoff_base: self.backoff_base,
+ backoff_max: self.backoff_max,
+ total_timeout: self.total_timeout,
+ user_agent: self.user_agent,
+ agent: ureq::Agent::new_with_config(config),
+ }
+ }
+}
+
+/// Client for the cep.is API.
+///
+/// Build one and reuse it: the configuration is immutable, no method keeps
+/// state between calls, and the underlying connection pool is shared — so a
+/// second lookup on the same host skips the TLS handshake.
+///
+/// ```no_run
+/// use std::time::Duration;
+///
+/// let client = cepis::Client::builder()
+/// .timeout(Duration::from_secs(5))
+/// .total_timeout(Some(Duration::from_secs(15)))
+/// .build();
+///
+/// let address = client.lookup("01001000")?;
+/// println!("{} {}", address.localidade, address.uf);
+/// # Ok::<_, cepis::Error>(())
+/// ```
+#[derive(Debug, Clone)]
+pub struct Client {
+ base_url: String,
+ timeout: Duration,
+ max_attempts: u32,
+ backoff_base: Duration,
+ backoff_max: Duration,
+ total_timeout: Option<Duration>,
+ user_agent: String,
+ agent: ureq::Agent,
+}
+
+impl Default for Client {
+ fn default() -> Self {
+ Client::new()
+ }
+}
+
+impl Client {
+ /// A client with the default configuration, pointing at <https://cep.is>.
+ pub fn new() -> Client {
+ ClientBuilder::default().build()
+ }
+
+ /// Start configuring a client.
+ pub fn builder() -> ClientBuilder {
+ ClientBuilder::default()
+ }
+
+ /// The API URL in use, without a trailing slash.
+ pub fn base_url(&self) -> &str {
+ &self.base_url
+ }
+
+ /// The `User-Agent` sent with every request.
+ pub fn user_agent(&self) -> &str {
+ &self.user_agent
+ }
+
+ /// The per-socket-operation timeout.
+ pub fn timeout(&self) -> Duration {
+ self.timeout
+ }
+
+ /// How many attempts each call makes at most, counting the first.
+ pub fn max_attempts(&self) -> u32 {
+ self.max_attempts
+ }
+
+ /// The cap on the whole call, if one is set.
+ pub fn total_timeout(&self) -> Option<Duration> {
+ self.total_timeout
+ }
+
+ /// Look up a single CEP.
+ ///
+ /// The CEP may be masked or not. This is the only way to obtain a
+ /// coordinate — see [`lookup_many`](Client::lookup_many) for why.
+ ///
+ /// A 400 from the API is surfaced as [`Error::InvalidCep`] carrying the
+ /// API's own message: the format already passed local validation, so the
+ /// server knows something this client does not.
+ ///
+ /// # Errors
+ ///
+ /// - [`Error::InvalidCep`] — not 8 digits (no request is made).
+ /// - [`Error::NotFound`] — well-formed, but not in the database.
+ /// - [`Error::RateLimited`] — 429 after every attempt.
+ /// - [`Error::Server`] — 5xx after every attempt.
+ /// - [`Error::Network`] — the request never completed.
+ /// - [`Error::InvalidResponse`] — the body could not be read as JSON.
+ pub fn lookup(&self, cep: &str) -> Result<Address> {
+ let number = normalize_cep(cep)?;
+ let (status, body) = self.request(&format!("/{number}"), None, &[200])?;
+
+ if status == 404 {
+ return Err(Error::NotFound { cep: number });
+ }
+ if status == 400 {
+ return Err(Error::invalid_cep(error_message(
+ &body,
+ &format!("CEP {number} rejected"),
+ )));
+ }
+
+ Ok(Address::from_json(json_object(&body)?))
+ }
+
+ /// Look up many CEPs in one batch.
+ ///
+ /// Returns a `Vec` of **the same length and in the same order** as the
+ /// input. A CEP that does not exist becomes `None` at its position — it
+ /// does not vanish from the list, and it is not an error. That is the right
+ /// difference from [`lookup`](Client::lookup): in a batch of a thousand,
+ /// one bad CEP must not cost the other nine hundred and ninety-nine.
+ ///
+ /// A malformed CEP also becomes `None`, for the same reason. If you need to
+ /// know *which* one was malformed, validate first with
+ /// [`normalize_cep`].
+ ///
+ /// Input is chunked into blocks of [`MAX_BATCH`] automatically. Do not loop
+ /// over [`lookup`](Client::lookup) instead: 500 calls pay 500 round trips,
+ /// the batch pays one.
+ ///
+ /// # Warning
+ ///
+ /// The batch carries **no coordinate**. The endpoint behind it is CSV, and
+ /// the CSV has no `geo` column. Every [`Address`] returned here has
+ /// `geo: None`, including for CEPs that do have one. Need `geo`? Use
+ /// [`lookup`](Client::lookup), one at a time.
+ ///
+ /// # Errors
+ ///
+ /// Transport failures only — [`Error::RateLimited`], [`Error::Server`],
+ /// [`Error::Network`], [`Error::InvalidResponse`]. A bad CEP never fails a
+ /// batch.
+ pub fn lookup_many(&self, ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> {
+ if ceps.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let mut result = Vec::with_capacity(ceps.len());
+ for chunk in ceps.chunks(MAX_BATCH) {
+ result.extend(self.lookup_chunk(chunk)?);
+ }
+ Ok(result)
+ }
+
+ /// 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 fail.
+ pub fn health(&self) -> Result<Health> {
+ let (_, body) = self.request("/health", None, &[200, 503])?;
+ Ok(Health::from_json(json_object(&body)?))
+ }
+
+ /// One chunk of at most [`MAX_BATCH`] CEPs, in a single request.
+ ///
+ /// A blank CSV row means "not found": the API fills only the `cep` column
+ /// and leaves the rest empty, precisely so it does not misalign whoever is
+ /// consuming the batch in a spreadsheet.
+ ///
+ /// The API guarantees one row per requested CEP. Should that ever fail to
+ /// hold — a proxy truncating, a cut response — the result is padded to the
+ /// input length rather than returned short, because a shifted list silently
+ /// pairs the wrong address with the wrong CEP.
+ fn lookup_chunk(&self, ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> {
+ let joined = ceps
+ .iter()
+ .map(|c| c.as_ref())
+ .collect::<Vec<_>>()
+ .join(",");
+ let (_, body) = self.request("/csv", Some(("ceps", joined)), &[200])?;
+
+ let text = String::from_utf8_lossy(&body);
+ let records = csv::parse(&text);
+ let mut rows: Vec<Option<Address>> = Vec::with_capacity(ceps.len());
+
+ if let Some((header, data)) = records.split_first() {
+ for record in data {
+ let mut object = Map::new();
+ for (column, value) in header.iter().zip(record.iter()) {
+ object.insert(column.clone(), Value::String(value.clone()));
+ }
+ let known = object
+ .get("localidade")
+ .and_then(Value::as_str)
+ .is_some_and(|v| !v.trim().is_empty());
+ rows.push(known.then(|| Address::from_json(Value::Object(object))));
+ }
+ }
+
+ rows.resize(ceps.len(), None);
+ Ok(rows)
+ }
+
+ /// Perform the GET, with retries. Returns `(status, body)`.
+ ///
+ /// *Definitive* error statuses (400, 404) come back normally, for the
+ /// caller to turn into the right error — only transient failures become
+ /// errors here, and only once the attempts are exhausted.
+ fn request(
+ &self,
+ path: &str,
+ query: Option<(&str, String)>,
+ accepted: &[u16],
+ ) -> Result<(u16, Vec<u8>)> {
+ let url = format!("{}{}", self.base_url, path);
+ let deadline = self.total_timeout.map(|cap| Instant::now() + cap);
+
+ let mut last_status: Option<u16> = None;
+ let mut last_retry_after: Option<Duration> = None;
+ let mut last_network: Option<ureq::Error> = None;
+
+ for attempt in 1..=self.max_attempts {
+ let remaining = match deadline {
+ Some(d) => {
+ let left = d.saturating_duration_since(Instant::now());
+ if left.is_zero() {
+ break;
+ }
+ Some(left)
+ }
+ None => None,
+ };
+
+ let mut request = self.agent.get(&url);
+ if let Some((key, value)) = &query {
+ request = request.query(key, value);
+ }
+ if let Some(left) = remaining {
+ request = request.config().timeout_global(Some(left)).build();
+ }
+
+ match request.call() {
+ Ok(mut response) => {
+ let status = response.status().as_u16();
+ let retry_after = response
+ .headers()
+ .get("retry-after")
+ .and_then(|v| v.to_str().ok())
+ .and_then(parse_retry_after);
+
+ match response.body_mut().read_to_vec() {
+ Ok(body) => {
+ if accepted.contains(&status) || !RETRIABLE_STATUS.contains(&status) {
+ return Ok((status, body));
+ }
+ last_status = Some(status);
+ last_retry_after = retry_after;
+ last_network = None;
+ }
+ Err(err) => {
+ last_status = None;
+ last_retry_after = None;
+ last_network = Some(err);
+ }
+ }
+ }
+ Err(ureq::Error::BadUri(detail)) => {
+ return Err(Error::network(
+ format!("{url} is not a usable URL: {detail}"),
+ None,
+ ));
+ }
+ Err(err) => {
+ last_status = None;
+ last_retry_after = None;
+ last_network = Some(err);
+ }
+ }
+
+ if attempt == self.max_attempts {
+ break;
+ }
+
+ let mut wait = self.backoff(attempt, last_retry_after);
+ if let Some(d) = deadline {
+ let left = d.saturating_duration_since(Instant::now());
+ if left.is_zero() {
+ break;
+ }
+ wait = wait.min(left);
+ }
+ std::thread::sleep(wait);
+ }
+
+ let attempts = self.max_attempts;
+ match (last_status, last_network) {
+ (Some(429), _) => Err(Error::RateLimited {
+ message: format!("rate limit reached at {url} (after {attempts} attempt(s))"),
+ retry_after: last_retry_after,
+ }),
+ (Some(status), _) => Err(Error::Server {
+ message: format!(
+ "the API answered {status} at {url} (after {attempts} attempt(s))"
+ ),
+ status,
+ }),
+ (None, source) => {
+ let detail = source
+ .as_ref()
+ .map(|e| e.to_string())
+ .unwrap_or_else(|| "no attempt was made within the total timeout".to_owned());
+ Err(Error::network(
+ format!("could not reach {url} (after {attempts} attempt(s)): {detail}"),
+ source.map(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
+ ))
+ }
+ }
+ }
+
+ /// How long to wait before the next attempt.
+ ///
+ /// The server's `Retry-After` wins over the local calculation when present:
+ /// it knows when it will be back, we are guessing. It still goes through
+ /// the `backoff_max` cap. Otherwise the wait is drawn from `[0, ceiling]`
+ /// rather than being the ceiling.
+ fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
+ if let Some(asked) = retry_after {
+ return asked.min(self.backoff_max);
+ }
+ let ceiling = self
+ .backoff_base
+ .saturating_mul(1u32 << (attempt - 1).min(31))
+ .min(self.backoff_max);
+ jitter(ceiling)
+ }
+}
+
+/// Decode a JSON object body, or fail with [`Error::InvalidResponse`].
+fn json_object(body: &[u8]) -> Result<Value> {
+ let value: Value = serde_json::from_slice(body).map_err(|err| {
+ Error::invalid_response(format!(
+ "response is not valid JSON — something between you and the API is \
+ answering instead of it: {}: {err}",
+ preview(body)
+ ))
+ })?;
+
+ if value.is_object() {
+ Ok(value)
+ } else {
+ Err(Error::invalid_response(format!(
+ "expected a JSON object, got {}",
+ preview(body)
+ )))
+ }
+}
+
+/// Pull `mensagem` out of an error body, falling back to a default.
+fn error_message(body: &[u8], fallback: &str) -> String {
+ serde_json::from_slice::<Value>(body)
+ .ok()
+ .as_ref()
+ .and_then(|v| v.get("mensagem"))
+ .and_then(Value::as_str)
+ .filter(|m| !m.is_empty())
+ .map(str::to_owned)
+ .unwrap_or_else(|| fallback.to_owned())
+}
+
+fn preview(body: &[u8]) -> String {
+ let text = String::from_utf8_lossy(body);
+ let cut = text
+ .char_indices()
+ .nth(120)
+ .map(|(i, _)| i)
+ .unwrap_or(text.len());
+ format!("{:?}", &text[..cut])
+}
diff --git a/src/csv.rs b/src/csv.rs
new file mode 100644
index 0000000..e434989
--- /dev/null
+++ b/src/csv.rs
@@ -0,0 +1,99 @@
+//! A small RFC 4180 reader for the `/csv` batch endpoint.
+//!
+//! Values may contain commas and are then double-quoted with `""` escaping,
+//! which is why the batch cannot be split on `,`. The reader is deliberately
+//! lenient: blank lines are skipped and a stray quote inside an unquoted field
+//! is kept as data, because a malformed byte in one row must not cost the whole
+//! batch.
+
+/// Split CSV text into records, honouring quoting, `""` escapes, embedded
+/// commas and newlines, CRLF and LF line endings, and a leading BOM.
+pub(crate) fn parse(input: &str) -> Vec<Vec<String>> {
+ let input = input.strip_prefix('\u{feff}').unwrap_or(input);
+
+ let mut records: Vec<Vec<String>> = Vec::new();
+ let mut record: Vec<String> = Vec::new();
+ let mut field = String::new();
+ let mut quoted = false;
+ let mut chars = input.chars().peekable();
+
+ while let Some(c) = chars.next() {
+ if quoted {
+ if c == '"' {
+ if chars.peek() == Some(&'"') {
+ chars.next();
+ field.push('"');
+ } else {
+ quoted = false;
+ }
+ } else {
+ field.push(c);
+ }
+ continue;
+ }
+
+ match c {
+ '"' if field.is_empty() => quoted = true,
+ ',' => record.push(std::mem::take(&mut field)),
+ '\n' | '\r' => {
+ if c == '\r' && chars.peek() == Some(&'\n') {
+ chars.next();
+ }
+ record.push(std::mem::take(&mut field));
+ records.push(std::mem::take(&mut record));
+ }
+ _ => field.push(c),
+ }
+ }
+
+ if !field.is_empty() || !record.is_empty() {
+ record.push(field);
+ records.push(record);
+ }
+
+ records.retain(|r| r.iter().any(|f| !f.is_empty()));
+ records
+}
+
+#[cfg(test)]
+mod tests {
+ use super::parse;
+
+ #[test]
+ fn splits_plain_rows() {
+ let records = parse("a,b,c\n1,2,3\n");
+ assert_eq!(records, vec![vec!["a", "b", "c"], vec!["1", "2", "3"]]);
+ }
+
+ #[test]
+ fn keeps_empty_fields() {
+ let records = parse("99999999,,,,,,,,,,,,\n");
+ assert_eq!(records.len(), 1);
+ assert_eq!(records[0].len(), 13);
+ }
+
+ #[test]
+ fn reads_quoted_field_with_comma() {
+ let records = parse("cep,logradouro\n01001000,\"Rua Um, Dois\"\n");
+ assert_eq!(records[1][1], "Rua Um, Dois");
+ }
+
+ #[test]
+ fn unescapes_doubled_quotes() {
+ let records = parse("a\n\"say \"\"hi\"\"\"\n");
+ assert_eq!(records[1][0], "say \"hi\"");
+ }
+
+ #[test]
+ fn accepts_embedded_newline_and_crlf() {
+ let records = parse("a,b\r\n\"line\nbreak\",2\r\n");
+ assert_eq!(records[1][0], "line\nbreak");
+ assert_eq!(records[1][1], "2");
+ }
+
+ #[test]
+ fn ignores_bom_and_blank_lines() {
+ let records = parse("\u{feff}a,b\n\n1,2");
+ assert_eq!(records, vec![vec!["a", "b"], vec!["1", "2"]]);
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..40a3049
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,150 @@
+//! Error type.
+//!
+//! Everything the library can fail with is one [`Error`], so a caller who only
+//! cares that *something* went wrong matches one type and is done.
+//!
+//! Below that, the split is by **the decision the caller has to make**, not by
+//! the HTTP status that caused it:
+//!
+//! - [`Error::InvalidCep`] and [`Error::NotFound`] are problems with the
+//! *request*. Retrying is pointless — the same CEP will fail again.
+//! - [`Error::Network`], [`Error::Server`] and [`Error::RateLimited`] are
+//! problems with *transport*. Retrying may help, and the client already
+//! retried on its own before returning any of them.
+//!
+//! That split is why `NotFound` is not "the HTTP 404 variant": the client also
+//! returns `InvalidCep` without making any request at all, when the CEP is
+//! malformed before it ever leaves the machine.
+
+use std::error::Error as StdError;
+use std::fmt;
+use std::time::Duration;
+
+/// The result of every fallible operation in this crate.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Everything that can go wrong when talking to the API.
+///
+/// Use [`Error::is_retriable`] when the choice is simply "give up or try again
+/// later"; match on the variants when the caller needs to tell a bad CEP from a
+/// bad network.
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Error {
+ /// The CEP is not 8 digits.
+ ///
+ /// Usually returned **without** a request ever being made — the client
+ /// checks the format before going to the network. It is also what a 400
+ /// from the API becomes, in which case the message is the API's own.
+ InvalidCep {
+ /// Human-readable explanation, including the offending value.
+ message: String,
+ },
+
+ /// The CEP is well-formed but does not exist in the DNE database.
+ NotFound {
+ /// The CEP that was looked up, unmasked.
+ cep: String,
+ },
+
+ /// The API (or the edge in front of it) answered 429 on every attempt.
+ RateLimited {
+ /// Human-readable explanation.
+ message: String,
+ /// What the server asked for via `Retry-After`, or `None` if it did not
+ /// say. The client already honoured it across the attempts it made; it
+ /// is exposed here for callers who want to reschedule the work.
+ retry_after: Option<Duration>,
+ },
+
+ /// The API answered 5xx on every attempt.
+ Server {
+ /// Human-readable explanation.
+ message: String,
+ /// The HTTP status seen on the last attempt.
+ status: u16,
+ },
+
+ /// The request never completed: DNS, refused connection, timeout, TLS.
+ ///
+ /// Returned only after every configured attempt has failed.
+ Network {
+ /// Human-readable explanation.
+ message: String,
+ /// The underlying transport error, when there is one.
+ source: Option<Box<dyn StdError + Send + Sync>>,
+ },
+
+ /// A success status came back with a body that cannot be parsed.
+ ///
+ /// In practice this means something between you and the API is answering
+ /// instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error
+ /// page. Useful as a diagnosis: the problem is not in your code.
+ InvalidResponse {
+ /// Human-readable explanation.
+ message: String,
+ },
+}
+
+impl Error {
+ /// Whether trying the same call again later could plausibly succeed.
+ ///
+ /// `true` for [`Error::RateLimited`], [`Error::Server`] and
+ /// [`Error::Network`]; `false` for a bad or unknown CEP, which fails the
+ /// same way forever.
+ ///
+ /// The client has already retried internally by the time any of these are
+ /// returned, so "later" means later — not immediately.
+ pub fn is_retriable(&self) -> bool {
+ matches!(
+ self,
+ Error::RateLimited { .. } | Error::Server { .. } | Error::Network { .. }
+ )
+ }
+
+ pub(crate) fn invalid_cep(message: impl Into<String>) -> Self {
+ Error::InvalidCep {
+ message: message.into(),
+ }
+ }
+
+ pub(crate) fn invalid_response(message: impl Into<String>) -> Self {
+ Error::InvalidResponse {
+ message: message.into(),
+ }
+ }
+
+ pub(crate) fn network(
+ message: impl Into<String>,
+ source: Option<Box<dyn StdError + Send + Sync>>,
+ ) -> Self {
+ Error::Network {
+ message: message.into(),
+ source,
+ }
+ }
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::InvalidCep { message } => f.write_str(message),
+ Error::NotFound { cep } => {
+ write!(f, "CEP {cep} does not exist in the DNE database")
+ }
+ Error::RateLimited { message, .. } => f.write_str(message),
+ Error::Server { message, .. } => f.write_str(message),
+ Error::Network { message, .. } => f.write_str(message),
+ Error::InvalidResponse { message } => f.write_str(message),
+ }
+ }
+}
+
+impl StdError for Error {
+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
+ match self {
+ Error::Network { source, .. } => source.as_ref().map(|e| e.as_ref() as &dyn StdError),
+ _ => None,
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..84262d7
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,100 @@
+//! Rust client for the public CEP API at [cep.is](https://cep.is). Official
+//! Correios data (DNE), refreshed every two weeks.
+//!
+//! Quick use — for a script, the free functions are enough:
+//!
+//! ```no_run
+//! let address = cepis::lookup("01001-000")?;
+//! println!("{} {}", address.localidade, address.uf); // São Paulo SP
+//! # Ok::<_, cepis::Error>(())
+//! ```
+//!
+//! In an application — build a [`Client`] once and reuse it:
+//!
+//! ```no_run
+//! use std::time::Duration;
+//!
+//! let client = cepis::Client::builder()
+//! .timeout(Duration::from_secs(5))
+//! .total_timeout(Some(Duration::from_secs(15)))
+//! .build();
+//!
+//! let address = client.lookup("01001000")?;
+//! # Ok::<_, cepis::Error>(())
+//! ```
+//!
+//! Pointing at a local instance (the API repo brings one up with `just up`):
+//!
+//! ```no_run
+//! let client = cepis::Client::builder()
+//! .base_url("http://localhost:8080")
+//! .build();
+//! # let _ = client;
+//! ```
+//!
+//! Errors are one type, split by decision — whether retrying helps or not:
+//!
+//! ```no_run
+//! # let cep_from_form = "01001000";
+//! match cepis::lookup(cep_from_form) {
+//! Ok(address) => println!("{}", address.localidade),
+//! Err(cepis::Error::NotFound { .. }) => {} // ask the user for another CEP
+//! Err(err) if err.is_retriable() => {} // network, server, rate limit
+//! Err(err) => eprintln!("{err}"),
+//! }
+//! ```
+//!
+//! Note on field names: the API surface is English, but the **data fields keep
+//! the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`).
+//! What you read in the JSON is what you access on the value.
+//!
+//! The free functions share one default client, built on first use. It keeps no
+//! state between calls and is safe to share across threads. Anyone needing
+//! different settings should build their own [`Client`].
+
+#![forbid(unsafe_code)]
+#![warn(missing_docs)]
+
+mod client;
+mod csv;
+mod error;
+mod model;
+mod retry;
+
+pub use crate::client::{
+ Client, ClientBuilder, DEFAULT_URL, MAX_BATCH, RETRIABLE_STATUS, normalize_cep,
+};
+pub use crate::error::{Error, Result};
+pub use crate::model::{Address, Geo, Health};
+
+/// The version of this library, as sent in the `User-Agent`.
+pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+
+use std::sync::OnceLock;
+
+fn default_client() -> &'static Client {
+ static DEFAULT: OnceLock<Client> = OnceLock::new();
+ DEFAULT.get_or_init(Client::new)
+}
+
+/// Look up a CEP using the default configuration.
+///
+/// Shorthand for `Client::new().lookup(cep)`; see [`Client::lookup`].
+pub fn lookup(cep: &str) -> Result<Address> {
+ default_client().lookup(cep)
+}
+
+/// Look up many CEPs in one batch, using the default configuration.
+///
+/// Shorthand for [`Client::lookup_many`] — read that one, especially the
+/// warning that batches carry no coordinate.
+pub fn lookup_many(ceps: &[impl AsRef<str>]) -> Result<Vec<Option<Address>>> {
+ default_client().lookup_many(ceps)
+}
+
+/// Query the API's health using the default configuration.
+///
+/// Shorthand for [`Client::health`].
+pub fn health() -> Result<Health> {
+ default_client().health()
+}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..59cacbe
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,227 @@
+//! The data the API returns, as types.
+//!
+//! **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+//! `localidade`. Everything else in this crate is English; the fields are the
+//! one deliberate exception.
+//!
+//! The reason is fidelity: what you read in the JSON at <https://cep.is/01001000>
+//! is exactly what you access on the value. Translating to `street`/`city`
+//! would mean maintaining a field-by-field mapping and forcing anyone reading
+//! the API docs alongside this crate to translate back in their head. The
+//! values are Portuguese either way — they come from the Correios database.
+
+use serde_json::Value;
+
+/// The CEP's coordinate, when the database has one.
+///
+/// The point is the **median** of the coordinates of that CEP's addresses, not
+/// the mean: one badly geocoded address 40 km away shifts a mean and does not
+/// move a median.
+///
+/// Always check [`precisao`](Geo::precisao) before using `lat`/`lon` for
+/// distance:
+///
+/// - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+/// 100–150 m. Good for maps and for distance.
+/// - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+/// CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+/// Computing shipping from this is wrong by tens of kilometres.
+///
+/// `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+/// precisely so that error cannot pass unnoticed.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Geo {
+ /// Latitude in decimal degrees, WGS 84.
+ pub lat: f64,
+ /// Longitude in decimal degrees, WGS 84.
+ pub lon: f64,
+ /// Radius, in metres, containing 90% of the CEP's addresses.
+ pub raio_m: i64,
+ /// `"logradouro"` (a street) or `"localidade"` (a municipality centroid).
+ pub precisao: String,
+}
+
+impl Geo {
+ /// `true` when the coordinate points at a street, not a municipality.
+ ///
+ /// Shorthand for `precisao == "logradouro"`, which is the check to make
+ /// before using the point to measure distance.
+ pub fn is_precise(&self) -> bool {
+ self.precisao == "logradouro"
+ }
+
+ fn from_json(value: &Value) -> Option<Geo> {
+ let object = value.as_object()?;
+ Some(Geo {
+ lat: number(object.get("lat")?)?,
+ lon: number(object.get("lon")?)?,
+ raio_m: number(object.get("raio_m")?)? as i64,
+ precisao: match object.get("precisao")? {
+ Value::String(s) => s.clone(),
+ _ => return None,
+ },
+ })
+ }
+}
+
+/// A CEP and everything the database knows about it.
+///
+/// Fields the database does not have for that CEP come back as empty strings,
+/// never absent — that is what the API returns, and changing it here would
+/// create a difference between reading the JSON and reading this crate.
+///
+/// [`geo`](Address::geo) is the exception: it is `None` when there is no
+/// coordinate. There the difference is real, because the API **omits** the
+/// whole field in that case (about 43% of CEPs) rather than sending null.
+///
+/// [`raw`](Address::raw) holds the response exactly as it arrived,
+/// uninterpreted, so a field the API grows before this crate is updated is not
+/// lost.
+#[derive(Debug, Clone, PartialEq)]
+#[non_exhaustive]
+pub struct Address {
+ /// The CEP, always unmasked: `"01001000"`.
+ pub cep: String,
+ /// Street, avenue, square — the public way.
+ pub logradouro: String,
+ /// Range or side of the street the CEP covers, when it covers only part.
+ pub complemento: String,
+ /// Name of the large user or building the CEP belongs to, when it does.
+ pub unidade: String,
+ /// Neighbourhood.
+ pub bairro: String,
+ /// Municipality.
+ pub localidade: String,
+ /// Federative unit, two letters: `"SP"`.
+ pub uf: String,
+ /// Federative unit spelled out: `"São Paulo"`.
+ pub estado: String,
+ /// Region: `"Sudeste"`, `"Nordeste"`, and so on.
+ pub regiao: String,
+ /// IBGE municipality code — the key to join with public datasets.
+ pub ibge: String,
+ /// GIA code, São Paulo state tax administration only.
+ pub gia: String,
+ /// Area code for telephony: `"11"`.
+ pub ddd: String,
+ /// SIAFI municipality code, federal administration.
+ pub siafi: String,
+ /// The coordinate, or `None` when the database has none for this CEP.
+ ///
+ /// Always `None` for an address that came from
+ /// [`lookup_many`](crate::Client::lookup_many) — batches carry no
+ /// coordinate at all.
+ pub geo: Option<Geo>,
+ /// The response exactly as it arrived, so a new API field is never lost.
+ pub raw: Value,
+}
+
+impl Address {
+ /// The CEP with its mask: `01001-000`.
+ ///
+ /// The native contract always returns it unmasked; formatting is
+ /// presentation, and it lives here so it does not become a repeated
+ /// `format!` in every project that uses this.
+ pub fn formatted_cep(&self) -> String {
+ if self.cep.len() == 8 {
+ format!("{}-{}", &self.cep[..5], &self.cep[5..])
+ } else {
+ self.cep.clone()
+ }
+ }
+
+ /// Build from the JSON object the API returned.
+ ///
+ /// Unknown fields are ignored during construction but preserved in
+ /// [`raw`](Address::raw). A new field in the API therefore cannot break
+ /// this crate.
+ ///
+ /// A malformed `geo` block yields `geo: None` rather than failing the whole
+ /// value: it is not worth losing the address over, since the rest of the
+ /// response is still useful.
+ pub fn from_json(value: Value) -> Address {
+ let geo = value.get("geo").and_then(Geo::from_json);
+ Address {
+ cep: text(&value, "cep"),
+ logradouro: text(&value, "logradouro"),
+ complemento: text(&value, "complemento"),
+ unidade: text(&value, "unidade"),
+ bairro: text(&value, "bairro"),
+ localidade: text(&value, "localidade"),
+ uf: text(&value, "uf"),
+ estado: text(&value, "estado"),
+ regiao: text(&value, "regiao"),
+ ibge: text(&value, "ibge"),
+ gia: text(&value, "gia"),
+ ddd: text(&value, "ddd"),
+ siafi: text(&value, "siafi"),
+ geo,
+ raw: value,
+ }
+ }
+}
+
+/// The response of `GET /health`.
+///
+/// Good for two things: checking that the API answers, and knowing **which
+/// version of the database** is live. The DNE database changes every two weeks;
+/// [`dne`](Health::dne) identifies it.
+#[derive(Debug, Clone, PartialEq)]
+#[non_exhaustive]
+pub struct Health {
+ /// `"ok"` when the API considers itself healthy.
+ pub status: String,
+ /// Identifier of the fortnightly DNE database currently loaded.
+ pub dne: String,
+ /// How many CEPs the database holds.
+ pub ceps: u64,
+ /// How many of those carry a coordinate.
+ pub geo: u64,
+ /// When the database was generated, RFC 3339 in UTC.
+ pub base_gerada_em: String,
+ /// The response exactly as it arrived.
+ pub raw: Value,
+}
+
+impl Health {
+ /// `true` when the API reports itself healthy.
+ pub fn ok(&self) -> bool {
+ self.status == "ok"
+ }
+
+ /// Build from the JSON object the API returned.
+ pub fn from_json(value: Value) -> Health {
+ Health {
+ status: text(&value, "status"),
+ dne: text(&value, "dne"),
+ ceps: count(&value, "ceps"),
+ geo: count(&value, "geo"),
+ base_gerada_em: text(&value, "base_gerada_em"),
+ raw: value,
+ }
+ }
+}
+
+fn text(value: &Value, key: &str) -> String {
+ match value.get(key) {
+ Some(Value::String(s)) => s.clone(),
+ Some(Value::Null) | None => String::new(),
+ Some(other) => other.to_string(),
+ }
+}
+
+fn count(value: &Value, key: &str) -> u64 {
+ match value.get(key) {
+ Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
+ Some(Value::String(s)) => s.parse().unwrap_or(0),
+ _ => 0,
+ }
+}
+
+fn number(value: &Value) -> Option<f64> {
+ match value {
+ Value::Number(n) => n.as_f64(),
+ Value::String(s) => s.parse().ok(),
+ _ => None,
+ }
+}
diff --git a/src/retry.rs b/src/retry.rs
new file mode 100644
index 0000000..3e69ff0
--- /dev/null
+++ b/src/retry.rs
@@ -0,0 +1,143 @@
+//! Backoff arithmetic and `Retry-After` parsing.
+
+use std::collections::hash_map::RandomState;
+use std::hash::{BuildHasher, Hasher};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+/// A random `Duration` in `[0, ceiling]` — *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.
+///
+/// The source is the standard library's hash seed mixed with the current time,
+/// which is plenty for spreading retries and is not suitable for anything
+/// cryptographic.
+pub(crate) fn jitter(ceiling: Duration) -> Duration {
+ let mut hasher = RandomState::new().build_hasher();
+ hasher.write_u128(
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos(),
+ );
+ let fraction = (hasher.finish() >> 11) as f64 / (1u64 << 53) as f64;
+ ceiling.mul_f64(fraction)
+}
+
+/// Parse the `Retry-After` header in both RFC 9110 forms.
+///
+/// It may arrive as a number of seconds (`"120"`) or as 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. A date already in the past yields `Duration::ZERO`; anything
+/// unparseable yields `None`.
+pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> {
+ let value = value.trim();
+ if value.is_empty() {
+ return None;
+ }
+
+ if let Ok(seconds) = value.parse::<f64>() {
+ if seconds.is_finite() {
+ return Some(Duration::try_from_secs_f64(seconds.max(0.0)).unwrap_or(Duration::ZERO));
+ }
+ }
+
+ let target = http_date_to_unix(value)?;
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
+ Some(Duration::from_secs((target - now).max(0) as u64))
+}
+
+/// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must
+/// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110
+/// still mentions are not accepted.
+fn http_date_to_unix(value: &str) -> Option<i64> {
+ let rest = value.split_once(", ").map(|(_, r)| r).unwrap_or(value);
+ let mut parts = rest.split_whitespace();
+
+ let day: i64 = parts.next()?.parse().ok()?;
+ let month = month_number(parts.next()?)?;
+ let year: i64 = parts.next()?.parse().ok()?;
+
+ let mut clock = parts.next()?.split(':');
+ let hour: i64 = clock.next()?.parse().ok()?;
+ let minute: i64 = clock.next()?.parse().ok()?;
+ let second: i64 = clock.next()?.parse().ok()?;
+
+ if !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
+ return None;
+ }
+
+ Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second)
+}
+
+fn month_number(name: &str) -> Option<i64> {
+ const MONTHS: [&str; 12] = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ];
+ MONTHS.iter().position(|m| *m == name).map(|i| i as i64 + 1)
+}
+
+/// Days between 1970-01-01 and the given proleptic Gregorian date.
+fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
+ let year = if month <= 2 { year - 1 } else { year };
+ let era = if year >= 0 { year } else { year - 399 } / 400;
+ let year_of_era = year - era * 400;
+ let month_shifted = (month + 9) % 12;
+ let day_of_year = (153 * month_shifted + 2) / 5 + day - 1;
+ let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
+ era * 146_097 + day_of_era - 719_468
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{days_from_civil, http_date_to_unix, jitter, parse_retry_after};
+ use std::time::Duration;
+
+ #[test]
+ fn reads_plain_seconds() {
+ assert_eq!(parse_retry_after(" 7 "), Some(Duration::from_secs(7)));
+ }
+
+ #[test]
+ fn negative_seconds_become_zero() {
+ assert_eq!(parse_retry_after("-5"), Some(Duration::ZERO));
+ }
+
+ #[test]
+ fn reads_an_http_date() {
+ let epoch = http_date_to_unix("Wed, 21 Oct 2026 07:28:00 GMT").unwrap();
+ assert_eq!(epoch, 1_792_567_680);
+ }
+
+ #[test]
+ fn a_date_in_the_past_is_zero() {
+ assert_eq!(
+ parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT"),
+ Some(Duration::ZERO)
+ );
+ }
+
+ #[test]
+ fn rejects_nonsense() {
+ assert_eq!(parse_retry_after("soon"), None);
+ assert_eq!(parse_retry_after(""), None);
+ }
+
+ #[test]
+ fn civil_days_match_known_dates() {
+ assert_eq!(days_from_civil(1970, 1, 1), 0);
+ assert_eq!(days_from_civil(2000, 3, 1), 11_017);
+ }
+
+ #[test]
+ fn jitter_stays_within_the_ceiling() {
+ let ceiling = Duration::from_millis(50);
+ for _ in 0..100 {
+ assert!(jitter(ceiling) <= ceiling);
+ }
+ }
+}