summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:38 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 13:10:34 -0300
commita2b10fd494fb389e556e40134031eb86e56e11e7 (patch)
tree08b6df8a06f2c7c15488b90d57a6d8286cd57a22
downloadcsharp-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.editorconfig31
-rw-r--r--.gitignore11
-rw-r--r--Cepis.sln36
-rw-r--r--LICENSE15
-rw-r--r--Makefile43
-rw-r--r--README.md239
-rw-r--r--src/Cepis/Address.cs205
-rw-r--r--src/Cepis/AssemblyInfo.cs3
-rw-r--r--src/Cepis/CepNotFoundException.cs27
-rw-r--r--src/Cepis/Cepis.csproj33
-rw-r--r--src/Cepis/CepisClient.cs714
-rw-r--r--src/Cepis/CepisClientOptions.cs92
-rw-r--r--src/Cepis/CepisException.cs39
-rw-r--r--src/Cepis/Csv.cs122
-rw-r--r--src/Cepis/Geo.cs67
-rw-r--r--src/Cepis/GeoConverter.cs89
-rw-r--r--src/Cepis/Health.cs51
-rw-r--r--src/Cepis/InvalidCepException.cs29
-rw-r--r--src/Cepis/InvalidResponseException.cs27
-rw-r--r--src/Cepis/LenientStringConverter.cs57
-rw-r--r--src/Cepis/NetworkException.cs27
-rw-r--r--src/Cepis/RateLimitedException.cs39
-rw-r--r--src/Cepis/ServerErrorException.cs29
-rw-r--r--tests/Cepis.Tests/BatchTests.cs120
-rw-r--r--tests/Cepis.Tests/Cepis.Tests.csproj26
-rw-r--r--tests/Cepis.Tests/ConfigurationTests.cs80
-rw-r--r--tests/Cepis.Tests/CsvTests.cs79
-rw-r--r--tests/Cepis.Tests/FakeServer.cs168
-rw-r--r--tests/Cepis.Tests/Fixtures.cs69
-rw-r--r--tests/Cepis.Tests/HealthTests.cs46
-rw-r--r--tests/Cepis.Tests/HttpTestBase.cs33
-rw-r--r--tests/Cepis.Tests/LookupTests.cs168
-rw-r--r--tests/Cepis.Tests/ModelTests.cs68
-rw-r--r--tests/Cepis.Tests/NormalizeTests.cs35
-rw-r--r--tests/Cepis.Tests/RetryTests.cs249
35 files changed, 3166 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..79e5662
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,31 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+
+[*.{json,yml,yaml,md}]
+indent_size = 2
+
+[*.{csproj,props,targets}]
+indent_size = 2
+
+[*.cs]
+csharp_using_directive_placement = outside_namespace:warning
+csharp_style_namespace_declarations = file_scoped:warning
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_prefer_braces = true:warning
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_readonly_field = true:warning
+csharp_style_var_when_type_is_apparent = true:suggestion
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9d3db90
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+bin/
+obj/
+artifacts/
+*.user
+*.suo
+.vs/
+.vscode/
+.idea/
+TestResults/
+*.nupkg
+*.snupkg
diff --git a/Cepis.sln b/Cepis.sln
new file mode 100644
index 0000000..243594c
--- /dev/null
+++ b/Cepis.sln
@@ -0,0 +1,36 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EE5A614A-2E37-49CC-81D9-DC456601DB65}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cepis", "src\Cepis\Cepis.csproj", "{F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{3FBB17D5-A9E3-465B-AF9B-CE379BFA1F18}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cepis.Tests", "tests\Cepis.Tests\Cepis.Tests.csproj", "{DECE4541-8244-4116-9999-E844611B1EA3}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DECE4541-8244-4116-9999-E844611B1EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DECE4541-8244-4116-9999-E844611B1EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DECE4541-8244-4116-9999-E844611B1EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DECE4541-8244-4116-9999-E844611B1EA3}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E} = {EE5A614A-2E37-49CC-81D9-DC456601DB65}
+ {DECE4541-8244-4116-9999-E844611B1EA3} = {3FBB17D5-A9E3-465B-AF9B-CE379BFA1F18}
+ EndGlobalSection
+EndGlobal
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2bdf654
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+DOTNET ?= dotnet
+CONFIGURATION ?= Release
+SOLUTION := Cepis.sln
+LIBRARY := src/Cepis/Cepis.csproj
+ARTIFACTS := artifacts
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build pack check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the suite against a local HTTP server'
+ @echo ' lint dotnet format --verify-no-changes + build -warnaserror (read-only)'
+ @echo ' format apply dotnet format (writes)'
+ @echo ' build build the solution in $(CONFIGURATION)'
+ @echo ' pack the NuGet package into $(ARTIFACTS)'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build output'
+ @echo
+ @echo 'Override the configuration with e.g. make build CONFIGURATION=Debug'
+
+test:
+ $(DOTNET) test $(SOLUTION) -c $(CONFIGURATION)
+
+lint:
+ $(DOTNET) format $(SOLUTION) --verify-no-changes --severity warn
+ $(DOTNET) build $(SOLUTION) -c $(CONFIGURATION) -warnaserror
+
+format:
+ $(DOTNET) format $(SOLUTION) --severity warn
+
+build:
+ $(DOTNET) build $(SOLUTION) -c $(CONFIGURATION)
+
+pack:
+ $(DOTNET) pack $(LIBRARY) -c $(CONFIGURATION) -o $(ARTIFACTS)
+
+check: lint test build
+
+clean:
+ $(DOTNET) clean $(SOLUTION) -c $(CONFIGURATION) || true
+ rm -rf $(ARTIFACTS)
+ find . -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} +
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8d25149
--- /dev/null
+++ b/README.md
@@ -0,0 +1,239 @@
+# Cepis
+
+C# client for the [public CEP API at cep.is](https://cep.is). Official Correios
+data (DNE), refreshed every two weeks.
+
+```csharp
+using Cepis;
+
+Address address = await CepisClient.Default.LookupAsync("01001-000");
+Console.WriteLine($"{address.Logradouro} {address.Localidade} {address.UF}");
+// Praça da Sé São Paulo SP
+```
+
+## Installation
+
+Requires .NET 8 or newer. There are no runtime dependencies: HTTP comes from
+`System.Net.Http`, JSON from `System.Text.Json`, and the CSV reader behind the
+batch endpoint is in the library because the BCL has none.
+
+```sh
+git clone https://git.cep.is/csharp.git cepis-csharp
+cd cepis-csharp
+make pack
+```
+
+That writes `artifacts/Cepis.0.1.0.nupkg`; add it from a local feed, or
+reference `src/Cepis/Cepis.csproj` directly.
+
+## Usage
+
+### A single lookup
+
+`LookupAsync` accepts the CEP masked or unmasked and returns an `Address`:
+
+```csharp
+Address a = await client.LookupAsync("01310-100");
+
+a.Logradouro; // "Avenida Paulista"
+a.Localidade; // "São Paulo"
+a.UF; // "SP"
+a.Ibge; // "3550308"
+a.Cep; // "01310100" (always unmasked)
+a.FormattedCep; // "01310-100"
+```
+
+The data fields keep the API's own Portuguese names, with only the casing
+adapted; a `JsonPropertyName` attribute maps each one to its wire name:
+
+| wire | property | wire | property |
+| --- | --- | --- | --- |
+| `cep` | `Cep` | `regiao` | `Regiao` |
+| `logradouro` | `Logradouro` | `ibge` | `Ibge` |
+| `complemento` | `Complemento` | `gia` | `Gia` |
+| `unidade` | `Unidade` | `ddd` | `Ddd` |
+| `bairro` | `Bairro` | `siafi` | `Siafi` |
+| `localidade` | `Localidade` | `geo.lat` / `geo.lon` | `Geo.Lat` / `Geo.Lon` |
+| `uf` | `UF` | `geo.raio_m` | `Geo.RaioM` |
+| `estado` | `Estado` | `geo.precisao` | `Geo.Precisao` |
+
+Fields the database does not have for that CEP come back as empty strings,
+never `null`. A field the API grows that this version does not model lands in
+`a.Extra`, so nothing is silently dropped.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `Geo` is `null`:
+
+```csharp
+if (a.Geo is { } geo)
+{
+ geo.Lat; // -23.567202
+ geo.Lon; // -46.649659
+ geo.RaioM; // 1084
+ geo.Precisao; // "logradouro"
+}
+```
+
+**Check `Precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `Precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`geo.IsPrecise` is the shorthand for `Precisao == "logradouro"`:
+
+```csharp
+decimal cost = a.Geo is { IsPrecise: true } geo
+ ? ShippingFrom(geo.Lat, geo.Lon)
+ : ShippingFromMunicipality(a.Ibge);
+```
+
+### Many CEPs at once
+
+Do not loop over `LookupAsync`. Use `LookupManyAsync`, which resolves up to 500
+CEPs per request:
+
+```csharp
+string[] ceps = ["01001000", "99999999", "20040020"];
+IReadOnlyList<Address?> result = await client.LookupManyAsync(ceps);
+
+for (int i = 0; i < ceps.Length; i++)
+{
+ Console.WriteLine(result[i] is null
+ ? $"{ceps[i]}: not found"
+ : $"{ceps[i]}: {result[i]!.Localidade}");
+}
+```
+
+The returned list has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes `null` at its position — it
+does not vanish, and it does not throw.
+
+Inputs longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV
+> has no `geo` column. Every `Address` from `LookupManyAsync` has `Geo == null`,
+> including for CEPs that do have one. Need the coordinate? Use `LookupAsync`,
+> one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```csharp
+Health h = await client.HealthAsync();
+h.Ok; // true
+h.Dne; // "26071" identifier of the fortnightly database
+h.Ceps; // 1596168
+h.BaseGeradaEm; // "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded does not throw — that arrives in `h.Status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `CepisClient` and reuse it. It keeps
+no state between calls, holds one pooled `HttpClient`, and is safe to share
+across threads:
+
+```csharp
+using var client = new CepisClient(new CepisClientOptions
+{
+ BaseUrl = "https://cep.is", // point at another instance
+ Timeout = TimeSpan.FromSeconds(10), // per attempt, connection included
+ MaxAttempts = 3, // counting the first; 1 disables retrying
+ BackoffBase = TimeSpan.FromMilliseconds(500), // base of the exponential wait
+ BackoffMax = TimeSpan.FromSeconds(8), // cap on each wait
+ TotalTimeout = TimeSpan.FromSeconds(15), // cap on the whole call; unset by default
+});
+
+Address address = await client.LookupAsync("01001000");
+```
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried. Every setting is documented on
+`CepisClientOptions`.
+
+`CepisClient.Default` is a shared client with the default configuration, built
+on first use. It applies process-wide, including to third-party libraries that
+also use this package, so do not dispose it; need a different configuration?
+Build your own.
+
+To take the `HttpClient` from `IHttpClientFactory` or a container, pass it in —
+it is then neither reconfigured nor disposed by this client:
+
+```csharp
+var client = new CepisClient(httpClientFactory.CreateClient("cepis"));
+```
+
+### Pointing at a local instance
+
+`BaseUrl` is the only setting that usually changes between environments. The API
+repo brings a local instance up with `just up`:
+
+```csharp
+using var client = new CepisClient(new CepisClientOptions
+{
+ BaseUrl = "http://localhost:8080",
+});
+```
+
+## Errors
+
+Everything derives from `CepisException`. Below that, the split is by **the
+decision you have to make**, not by the HTTP status that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `InvalidCepException` | not 8 digits | no |
+| `CepNotFoundException` | well-formed, not in the database | no |
+| `RateLimitedException` | 429; carries `RetryAfter` | yes, later |
+| `ServerErrorException` | 5xx; carries `Status` | yes, later |
+| `NetworkException` | DNS, connection, timeout, TLS | yes, later |
+| `InvalidResponseException` | unintelligible response | probably not |
+
+```csharp
+try
+{
+ Address address = await client.LookupAsync(cepFromForm);
+}
+catch (CepNotFoundException)
+{
+ // ask the user for another CEP
+}
+catch (CepisException)
+{
+ // network, server, rate limit: reschedule
+}
+```
+
+C# allows one base class, so these are not also `ArgumentException` or
+`KeyNotFoundException`: one base type that catches every failure is worth more.
+A cancellation you asked for is not a `CepisException` — it surfaces as
+`OperationCanceledException`, untouched.
+
+`InvalidResponseException` almost always 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.
+
+## Development
+
+Everything runs through `make` and the .NET SDK:
+
+```sh
+make # list the targets
+make test # run the suite against a local HTTP server
+make lint # dotnet format --verify-no-changes + build -warnaserror, touches nothing
+make format # apply dotnet format
+make build # build the solution
+make pack # the NuGet package into artifacts/
+make check # lint, then test, then build
+```
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/src/Cepis/Address.cs b/src/Cepis/Address.cs
new file mode 100644
index 0000000..edc7776
--- /dev/null
+++ b/src/Cepis/Address.cs
@@ -0,0 +1,205 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Cepis;
+
+/// <summary>
+/// A CEP and everything the database knows about it.
+/// </summary>
+/// <remarks>
+/// <para>
+/// The property names are the API's own, in Portuguese, with only the casing
+/// adapted to C#. Everything else in this library is English; the data fields
+/// are the one deliberate exception, so that what you read in the JSON at
+/// <c>https://cep.is/01001000</c> is what you access on the object. The
+/// mapping from wire name to property is:
+/// </para>
+/// <list type="table">
+/// <listheader><term>wire</term><description>property</description></listheader>
+/// <item><term><c>cep</c></term><description><see cref="Cep"/></description></item>
+/// <item><term><c>logradouro</c></term><description><see cref="Logradouro"/></description></item>
+/// <item><term><c>complemento</c></term><description><see cref="Complemento"/></description></item>
+/// <item><term><c>unidade</c></term><description><see cref="Unidade"/></description></item>
+/// <item><term><c>bairro</c></term><description><see cref="Bairro"/></description></item>
+/// <item><term><c>localidade</c></term><description><see cref="Localidade"/></description></item>
+/// <item><term><c>uf</c></term><description><see cref="UF"/></description></item>
+/// <item><term><c>estado</c></term><description><see cref="Estado"/></description></item>
+/// <item><term><c>regiao</c></term><description><see cref="Regiao"/></description></item>
+/// <item><term><c>ibge</c></term><description><see cref="Ibge"/></description></item>
+/// <item><term><c>gia</c></term><description><see cref="Gia"/></description></item>
+/// <item><term><c>ddd</c></term><description><see cref="Ddd"/></description></item>
+/// <item><term><c>siafi</c></term><description><see cref="Siafi"/></description></item>
+/// <item><term><c>geo</c></term><description><see cref="Geo"/></description></item>
+/// </list>
+/// <para>
+/// Fields the database does not have for that CEP come back as empty strings,
+/// never <see langword="null"/> — that is what the API returns.
+/// </para>
+/// <para>
+/// <see cref="Geo"/> is the exception: it is <see langword="null"/> when there
+/// is no coordinate. There the difference is real, because the API <b>omits</b>
+/// the whole field in that case (about 43% of CEPs) rather than sending null.
+/// It is also always <see langword="null"/> for an address that came from
+/// <see cref="CepisClient.LookupManyAsync(IEnumerable{string}, CancellationToken)"/>,
+/// which carries no coordinate at all.
+/// </para>
+/// <para>
+/// <see cref="Extra"/> holds any field the API has grown that this version of
+/// the library does not model, so an outdated client loses nothing. It takes no
+/// part in equality, hashing or <see cref="object.ToString"/>: two addresses
+/// describing the same place are equal even if one of them was built by hand.
+/// </para>
+/// </remarks>
+public sealed record Address
+{
+ /// <summary>The eight digits, always unmasked. Wire name <c>cep</c>.</summary>
+ [JsonPropertyName("cep")]
+ public string Cep { get; init; } = "";
+
+ /// <summary>The street. Wire name <c>logradouro</c>.</summary>
+ [JsonPropertyName("logradouro")]
+ public string Logradouro { get; init; } = "";
+
+ /// <summary>
+ /// The qualifier on the street, such as a range of numbers. Wire name
+ /// <c>complemento</c>.
+ /// </summary>
+ [JsonPropertyName("complemento")]
+ public string Complemento { get; init; } = "";
+
+ /// <summary>
+ /// The named unit, for a CEP that belongs to one. Wire name <c>unidade</c>.
+ /// </summary>
+ [JsonPropertyName("unidade")]
+ public string Unidade { get; init; } = "";
+
+ /// <summary>The neighbourhood. Wire name <c>bairro</c>.</summary>
+ [JsonPropertyName("bairro")]
+ public string Bairro { get; init; } = "";
+
+ /// <summary>The municipality. Wire name <c>localidade</c>.</summary>
+ [JsonPropertyName("localidade")]
+ public string Localidade { get; init; } = "";
+
+ /// <summary>The two-letter state code. Wire name <c>uf</c>.</summary>
+ [JsonPropertyName("uf")]
+ public string UF { get; init; } = "";
+
+ /// <summary>The state's name. Wire name <c>estado</c>.</summary>
+ [JsonPropertyName("estado")]
+ public string Estado { get; init; } = "";
+
+ /// <summary>The region of the country. Wire name <c>regiao</c>.</summary>
+ [JsonPropertyName("regiao")]
+ public string Regiao { get; init; } = "";
+
+ /// <summary>The IBGE municipality code. Wire name <c>ibge</c>.</summary>
+ [JsonPropertyName("ibge")]
+ public string Ibge { get; init; } = "";
+
+ /// <summary>The GIA code, São Paulo only. Wire name <c>gia</c>.</summary>
+ [JsonPropertyName("gia")]
+ public string Gia { get; init; } = "";
+
+ /// <summary>The telephone area code. Wire name <c>ddd</c>.</summary>
+ [JsonPropertyName("ddd")]
+ public string Ddd { get; init; } = "";
+
+ /// <summary>The SIAFI municipality code. Wire name <c>siafi</c>.</summary>
+ [JsonPropertyName("siafi")]
+ public string Siafi { get; init; } = "";
+
+ /// <summary>
+ /// The coordinate, or <see langword="null"/> when there is none. Wire name
+ /// <c>geo</c>.
+ /// </summary>
+ /// <remarks>
+ /// A malformed <c>geo</c> block yields <see langword="null"/> rather than
+ /// failing the whole response: the rest of the address is still useful.
+ /// </remarks>
+ [JsonPropertyName("geo")]
+ [JsonConverter(typeof(GeoConverter))]
+ public Geo? Geo { get; init; }
+
+ /// <summary>
+ /// Fields the API returned that this version of the library does not model.
+ /// </summary>
+ /// <remarks>
+ /// A new field in the API therefore cannot break this client, and cannot be
+ /// silently dropped either. Empty for an address built by hand.
+ /// </remarks>
+ [JsonExtensionData]
+ public Dictionary<string, JsonElement>? Extra { get; init; }
+
+ /// <summary>The CEP with its mask, <c>01001-000</c>.</summary>
+ /// <remarks>
+ /// The API always returns it unmasked; formatting is presentation, and it
+ /// lives here so it does not become a repeated interpolation in every
+ /// project that uses this library. A CEP that is not eight characters long
+ /// is returned unchanged.
+ /// </remarks>
+ [JsonIgnore]
+ public string FormattedCep => Cep.Length == 8 ? $"{Cep[..5]}-{Cep[5..]}" : Cep;
+
+ /// <summary>Compare every field but <see cref="Extra"/>.</summary>
+ /// <param name="other">the address to compare with</param>
+ /// <returns><see langword="true"/> when both describe the same place</returns>
+ public bool Equals(Address? other) =>
+ other is not null
+ && Cep == other.Cep
+ && Logradouro == other.Logradouro
+ && Complemento == other.Complemento
+ && Unidade == other.Unidade
+ && Bairro == other.Bairro
+ && Localidade == other.Localidade
+ && UF == other.UF
+ && Estado == other.Estado
+ && Regiao == other.Regiao
+ && Ibge == other.Ibge
+ && Gia == other.Gia
+ && Ddd == other.Ddd
+ && Siafi == other.Siafi
+ && Geo == other.Geo;
+
+ /// <summary>Hash every field but <see cref="Extra"/>.</summary>
+ /// <returns>a hash consistent with <see cref="Equals(Address)"/></returns>
+ public override int GetHashCode()
+ {
+ var hash = default(HashCode);
+ hash.Add(Cep);
+ hash.Add(Logradouro);
+ hash.Add(Complemento);
+ hash.Add(Unidade);
+ hash.Add(Bairro);
+ hash.Add(Localidade);
+ hash.Add(UF);
+ hash.Add(Estado);
+ hash.Add(Regiao);
+ hash.Add(Ibge);
+ hash.Add(Gia);
+ hash.Add(Ddd);
+ hash.Add(Siafi);
+ hash.Add(Geo);
+ return hash.ToHashCode();
+ }
+
+ private bool PrintMembers(StringBuilder builder)
+ {
+ builder.Append("Cep = ").Append(Cep)
+ .Append(", Logradouro = ").Append(Logradouro)
+ .Append(", Complemento = ").Append(Complemento)
+ .Append(", Unidade = ").Append(Unidade)
+ .Append(", Bairro = ").Append(Bairro)
+ .Append(", Localidade = ").Append(Localidade)
+ .Append(", UF = ").Append(UF)
+ .Append(", Estado = ").Append(Estado)
+ .Append(", Regiao = ").Append(Regiao)
+ .Append(", Ibge = ").Append(Ibge)
+ .Append(", Gia = ").Append(Gia)
+ .Append(", Ddd = ").Append(Ddd)
+ .Append(", Siafi = ").Append(Siafi)
+ .Append(", Geo = ").Append(Geo?.ToString() ?? "null");
+ return true;
+ }
+}
diff --git a/src/Cepis/AssemblyInfo.cs b/src/Cepis/AssemblyInfo.cs
new file mode 100644
index 0000000..88c4636
--- /dev/null
+++ b/src/Cepis/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Cepis.Tests")]
diff --git a/src/Cepis/CepNotFoundException.cs b/src/Cepis/CepNotFoundException.cs
new file mode 100644
index 0000000..ced7b16
--- /dev/null
+++ b/src/Cepis/CepNotFoundException.cs
@@ -0,0 +1,27 @@
+namespace Cepis;
+
+/// <summary>
+/// The CEP is well-formed but does not exist in the DNE database.
+/// </summary>
+/// <remarks>
+/// Retrying does not help: the answer is definitive until the next fortnightly
+/// database. A batch never throws this — a CEP with no address is
+/// <see langword="null"/> at its position instead.
+/// </remarks>
+public sealed class CepNotFoundException : CepisException
+{
+ /// <summary>Create the exception with a message.</summary>
+ /// <param name="message">what went wrong</param>
+ public CepNotFoundException(string message)
+ : base(message)
+ {
+ }
+
+ /// <summary>Create the exception with a message and an underlying cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="innerException">the failure underneath</param>
+ public CepNotFoundException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Cepis/Cepis.csproj b/src/Cepis/Cepis.csproj
new file mode 100644
index 0000000..16ffb75
--- /dev/null
+++ b/src/Cepis/Cepis.csproj
@@ -0,0 +1,33 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <LangVersion>12.0</LangVersion>
+ <AssemblyName>Cepis</AssemblyName>
+ <RootNamespace>Cepis</RootNamespace>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ <EnableNETAnalyzers>true</EnableNETAnalyzers>
+ <AnalysisLevel>latest</AnalysisLevel>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageId>Cepis</PackageId>
+ <Version>0.1.0</Version>
+ <Authors>Murilo Ijanc</Authors>
+ <Description>Client for the public CEP API at cep.is. Official Correios data (DNE), refreshed every two weeks.</Description>
+ <PackageLicenseExpression>ISC</PackageLicenseExpression>
+ <PackageReadmeFile>README.md</PackageReadmeFile>
+ <PackageProjectUrl>https://cep.is</PackageProjectUrl>
+ <PackageTags>cep;correios;brasil;address;geocoding</PackageTags>
+ <IncludeSymbols>true</IncludeSymbols>
+ <SymbolPackageFormat>snupkg</SymbolPackageFormat>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Include="../../README.md" Pack="true" PackagePath="/" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Cepis/CepisClient.cs b/src/Cepis/CepisClient.cs
new file mode 100644
index 0000000..72637c0
--- /dev/null
+++ b/src/Cepis/CepisClient.cs
@@ -0,0 +1,714 @@
+using System.Collections.Frozen;
+using System.Globalization;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+
+namespace Cepis;
+
+/// <summary>
+/// Client for the public CEP API at <see href="https://cep.is">cep.is</see>.
+/// </summary>
+/// <remarks>
+/// <para>
+/// Build one and reuse it. The configuration is immutable, no method keeps
+/// state between calls, and the single underlying <see cref="HttpClient"/>
+/// pools connections — sharing one instance across threads is both safe and
+/// faster than creating them. A client per request is the classic way to run a
+/// .NET process out of sockets.
+/// </para>
+/// <example>
+/// <code>
+/// using var client = new CepisClient(new CepisClientOptions
+/// {
+/// BaseUrl = "http://localhost:8080",
+/// });
+///
+/// Address address = await client.LookupAsync("01001-000");
+/// </code>
+/// </example>
+/// <para>
+/// For a script, <see cref="Default"/> is a shared client with the default
+/// configuration, built on first use.
+/// </para>
+/// <para>
+/// Retries cover 429, the transient 5xx and network failures, with exponential
+/// backoff and full jitter. 400 and 404 are never retried: both are definitive
+/// answers about the request, so retrying returns the same thing more slowly
+/// while burning someone else's quota.
+/// </para>
+/// <para>
+/// A note on names: the API surface is English, but the <b>data fields keep the
+/// API's own Portuguese names</b> (<see cref="Address.Logradouro"/>,
+/// <see cref="Address.Bairro"/>, <see cref="Address.Localidade"/>), with only
+/// the casing adapted. What you read in the JSON is what you access on the
+/// object.
+/// </para>
+/// </remarks>
+public sealed class CepisClient : IDisposable
+{
+ /// <summary>Where the public API lives.</summary>
+ public const string DefaultUrl = "https://cep.is";
+
+ /// <summary>How many CEPs the batch endpoint accepts in one request.</summary>
+ public const int MaxBatch = 500;
+
+ /// <summary>The version of this library.</summary>
+ public const string Version = "0.1.0";
+
+ private const string AcceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1";
+
+ private static readonly FrozenSet<int> RetriableStatus =
+ new[] { 429, 500, 502, 503, 504 }.ToFrozenSet();
+
+ private static readonly FrozenSet<int> OkOnly = new[] { 200 }.ToFrozenSet();
+
+ private static readonly FrozenSet<int> HealthStatus = new[] { 200, 503 }.ToFrozenSet();
+
+ private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
+
+ private static readonly Lazy<CepisClient> SharedDefault = new(() => new CepisClient());
+
+ private readonly HttpClient _http;
+ private readonly bool _ownsHttp;
+ private readonly string _baseUrl;
+ private readonly CepisClientOptions _options;
+
+ /// <summary>Build a client with the default configuration.</summary>
+ public CepisClient()
+ : this(new CepisClientOptions())
+ {
+ }
+
+ /// <summary>Build a client with its own <see cref="HttpClient"/>.</summary>
+ /// <param name="options">the configuration</param>
+ /// <exception cref="ArgumentNullException"><paramref name="options"/> is null</exception>
+ /// <exception cref="ArgumentException">a setting is out of range</exception>
+ public CepisClient(CepisClientOptions options)
+ : this(NewHttpClient(), options, ownsHttp: true)
+ {
+ }
+
+ /// <summary>
+ /// Build a client over an <see cref="HttpClient"/> the caller owns.
+ /// </summary>
+ /// <remarks>
+ /// For applications that resolve their <see cref="HttpClient"/> from
+ /// <c>IHttpClientFactory</c> or a container. The instance is neither
+ /// reconfigured nor disposed by this client, so its own
+ /// <see cref="HttpClient.Timeout"/> still applies on top of
+ /// <see cref="CepisClientOptions.Timeout"/> — whichever is shorter wins.
+ /// </remarks>
+ /// <param name="httpClient">the transport to use</param>
+ /// <param name="options">the configuration, or null for the defaults</param>
+ /// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is null</exception>
+ /// <exception cref="ArgumentException">a setting is out of range</exception>
+ public CepisClient(HttpClient httpClient, CepisClientOptions? options = null)
+ : this(httpClient, options ?? new CepisClientOptions(), ownsHttp: false)
+ {
+ }
+
+ private CepisClient(HttpClient httpClient, CepisClientOptions options, bool ownsHttp)
+ {
+ ArgumentNullException.ThrowIfNull(httpClient);
+ ArgumentNullException.ThrowIfNull(options);
+ Validate(options);
+
+ _http = httpClient;
+ _ownsHttp = ownsHttp;
+ _options = options;
+ _baseUrl = options.BaseUrl.Trim().TrimEnd('/');
+ UserAgent = options.UserAgent ?? $"cepis-csharp/{Version} (+https://cep.is)";
+ }
+
+ /// <summary>
+ /// A shared client with the default configuration, built on first use.
+ /// </summary>
+ /// <remarks>
+ /// Convenient for scripts and one-off lookups. It applies process-wide,
+ /// including to third-party libraries that also use this package, so do not
+ /// dispose it; anyone needing different settings should build their own.
+ /// </remarks>
+ public static CepisClient Default => SharedDefault.Value;
+
+ /// <summary>Where the API lives, without a trailing slash.</summary>
+ public string BaseUrl => _baseUrl;
+
+ /// <summary>The configuration this client was built with.</summary>
+ public CepisClientOptions Options => _options;
+
+ /// <summary>The <c>User-Agent</c> this client sends.</summary>
+ public string UserAgent { get; }
+
+ /// <summary>
+ /// Return the CEP's eight digits, accepting the mask.
+ /// </summary>
+ /// <remarks>
+ /// <c>"01001-000"</c>, <c>"01001000"</c> and <c>" 01001-000 "</c> all become
+ /// <c>"01001000"</c>. This runs <b>before</b> any request: a seven-digit CEP
+ /// is the caller's bug, and spending a round trip for the server to say so
+ /// wastes both sides' time.
+ /// </remarks>
+ /// <param name="value">the CEP, masked or not</param>
+ /// <returns>the eight digits</returns>
+ /// <exception cref="InvalidCepException">unless exactly eight digits remain</exception>
+ public static string NormalizeCep(string value)
+ {
+ if (value is null)
+ {
+ throw new InvalidCepException("the CEP must not be null");
+ }
+
+ var cleaned = value.Trim().Replace("-", "", StringComparison.Ordinal)
+ .Replace(".", "", StringComparison.Ordinal)
+ .Replace(" ", "", StringComparison.Ordinal);
+
+ if (!IsEightDigits(cleaned))
+ {
+ throw new InvalidCepException($"the CEP must be 8 digits, got \"{value}\"");
+ }
+
+ return cleaned;
+ }
+
+ /// <summary>
+ /// Look up a single CEP.
+ /// </summary>
+ /// <remarks>
+ /// This is the only call that carries a coordinate. About 57% of CEPs have
+ /// one; when they do not, <see cref="Address.Geo"/> is
+ /// <see langword="null"/>.
+ /// </remarks>
+ /// <param name="cep">the CEP, masked or not</param>
+ /// <param name="cancellationToken">cancels the call, waits included</param>
+ /// <returns>the address</returns>
+ /// <exception cref="InvalidCepException">
+ /// the CEP is not eight digits, in which case no request is made, or the API
+ /// rejected it with a 400
+ /// </exception>
+ /// <exception cref="CepNotFoundException">well-formed, but not in the database</exception>
+ /// <exception cref="RateLimitedException">the API answered 429 on every attempt</exception>
+ /// <exception cref="ServerErrorException">the API answered 5xx on every attempt</exception>
+ /// <exception cref="NetworkException">the request never completed</exception>
+ /// <exception cref="InvalidResponseException">the body cannot be read</exception>
+ public async Task<Address> LookupAsync(string cep, CancellationToken cancellationToken = default)
+ {
+ var number = NormalizeCep(cep);
+ var reply = await RequestAsync($"/{number}", OkOnly, cancellationToken).ConfigureAwait(false);
+
+ if (reply.Status == 404)
+ {
+ throw new CepNotFoundException($"CEP {number} does not exist in the DNE database");
+ }
+
+ if (reply.Status == 400)
+ {
+ throw new InvalidCepException(
+ ErrorMessage(reply.Body, $"CEP {number} was rejected by the API"));
+ }
+
+ return Deserialize<Address>(reply.Body);
+ }
+
+ /// <summary>
+ /// Look up many CEPs in one batch.
+ /// </summary>
+ /// <remarks>
+ /// <para>
+ /// The returned list has <b>the same length and the same order</b> as the
+ /// input. A CEP that does not exist becomes <see langword="null"/> at its
+ /// position — it does not vanish from the list, and it does not throw. That
+ /// is the right difference from
+ /// <see cref="LookupAsync(string, CancellationToken)"/>: in a batch of a
+ /// thousand, one bad CEP must not cost the other nine hundred and
+ /// ninety-nine.
+ /// </para>
+ /// <para>
+ /// A malformed CEP also becomes <see langword="null"/>, for the same reason.
+ /// If you need to know <i>which</i> one was malformed, validate first with
+ /// <see cref="NormalizeCep(string)"/>.
+ /// </para>
+ /// <para>
+ /// Input longer than <see cref="MaxBatch"/> is chunked into that many CEPs
+ /// per request automatically.
+ /// </para>
+ /// <para>
+ /// <b>Batches carry no coordinate.</b> The endpoint behind them is CSV, and
+ /// the CSV has no <c>geo</c> column. Every address returned here has
+ /// <see cref="Address.Geo"/> <see langword="null"/>, including for CEPs that
+ /// do have one. Need the coordinate? Use
+ /// <see cref="LookupAsync(string, CancellationToken)"/>, one at a time.
+ /// </para>
+ /// </remarks>
+ /// <param name="ceps">the CEPs, masked or not</param>
+ /// <param name="cancellationToken">cancels the call, waits included</param>
+ /// <returns>
+ /// one entry per input CEP, in order, <see langword="null"/> where there is
+ /// no address
+ /// </returns>
+ /// <exception cref="ArgumentNullException"><paramref name="ceps"/> is null</exception>
+ /// <exception cref="RateLimitedException">the API answered 429 on every attempt</exception>
+ /// <exception cref="ServerErrorException">the API answered 5xx on every attempt</exception>
+ /// <exception cref="NetworkException">a request never completed</exception>
+ public async Task<IReadOnlyList<Address?>> LookupManyAsync(
+ IEnumerable<string> ceps,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(ceps);
+
+ var wanted = ceps as IReadOnlyList<string> ?? [.. ceps];
+ if (wanted.Count == 0)
+ {
+ return [];
+ }
+
+ var result = new List<Address?>(wanted.Count);
+ for (var start = 0; start < wanted.Count; start += MaxBatch)
+ {
+ var end = Math.Min(start + MaxBatch, wanted.Count);
+ var chunk = new List<string>(end - start);
+ for (var i = start; i < end; i++)
+ {
+ chunk.Add(wanted[i]);
+ }
+
+ result.AddRange(await LookupChunkAsync(chunk, cancellationToken).ConfigureAwait(false));
+ }
+
+ return result;
+ }
+
+ /// <summary>
+ /// Query <c>GET /health</c>: does the API answer, and with which database.
+ /// </summary>
+ /// <remarks>
+ /// An API reporting itself degraded does not throw — that arrives in
+ /// <see cref="Health.Status"/>, and checking health is exactly when you want
+ /// the diagnosis rather than an exception. Network failures still throw.
+ /// </remarks>
+ /// <param name="cancellationToken">cancels the call, waits included</param>
+ /// <returns>the API's health</returns>
+ /// <exception cref="NetworkException">the request never completed</exception>
+ /// <exception cref="InvalidResponseException">the body cannot be read</exception>
+ public async Task<Health> HealthAsync(CancellationToken cancellationToken = default)
+ {
+ var reply = await RequestAsync("/health", HealthStatus, cancellationToken).ConfigureAwait(false);
+ return Deserialize<Health>(reply.Body);
+ }
+
+ /// <summary>
+ /// Release the <see cref="HttpClient"/> this client created for itself.
+ /// </summary>
+ /// <remarks>
+ /// An <see cref="HttpClient"/> handed in by the caller is left alone.
+ /// </remarks>
+ public void Dispose()
+ {
+ if (_ownsHttp)
+ {
+ _http.Dispose();
+ }
+ }
+
+ /// <summary>
+ /// Parse the <c>Retry-After</c> header in both RFC 9110 forms.
+ /// </summary>
+ /// <remarks>
+ /// It may arrive as a number of seconds (<c>"120"</c>) or as an HTTP date
+ /// (<c>"Wed, 21 Oct 2026 07:28:00 GMT"</c>). Handling only the first is the
+ /// common bug, and it makes a client ignore exactly the wait request that
+ /// matters most.
+ /// </remarks>
+ /// <param name="value">the header value, if any</param>
+ /// <returns>the wait, or null when there is nothing usable</returns>
+ internal static TimeSpan? ParseRetryAfter(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ var header = value.Trim();
+ if (double.TryParse(header, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds))
+ {
+ return TimeSpan.FromSeconds(Math.Max(0, seconds));
+ }
+
+ const DateTimeStyles styles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
+ if (!DateTimeOffset.TryParseExact(header, "r", CultureInfo.InvariantCulture, styles, out var target)
+ && !DateTimeOffset.TryParse(header, CultureInfo.InvariantCulture, styles, out target))
+ {
+ return null;
+ }
+
+ var wait = target - DateTimeOffset.UtcNow;
+ return wait < TimeSpan.Zero ? TimeSpan.Zero : wait;
+ }
+
+ private static HttpClient NewHttpClient() =>
+ new(new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5),
+ AutomaticDecompression = System.Net.DecompressionMethods.All,
+ })
+ {
+ Timeout = System.Threading.Timeout.InfiniteTimeSpan,
+ };
+
+ private static JsonSerializerOptions CreateJsonOptions()
+ {
+ var options = new JsonSerializerOptions(JsonSerializerDefaults.General)
+ {
+ NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ AllowTrailingCommas = false,
+ };
+ options.Converters.Add(new LenientStringConverter());
+ return options;
+ }
+
+ private static void Validate(CepisClientOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(options.BaseUrl))
+ {
+ throw new ArgumentException("BaseUrl must not be empty", nameof(options));
+ }
+
+ if (options.MaxAttempts < 1)
+ {
+ throw new ArgumentException("MaxAttempts must be >= 1", nameof(options));
+ }
+
+ if (options.Timeout <= TimeSpan.Zero)
+ {
+ throw new ArgumentException("Timeout must be > 0", nameof(options));
+ }
+
+ if (options.BackoffBase < TimeSpan.Zero)
+ {
+ throw new ArgumentException("BackoffBase must be >= 0", nameof(options));
+ }
+
+ if (options.BackoffMax < TimeSpan.Zero)
+ {
+ throw new ArgumentException("BackoffMax must be >= 0", nameof(options));
+ }
+
+ if (options.TotalTimeout is { } total && total <= TimeSpan.Zero)
+ {
+ throw new ArgumentException("TotalTimeout must be > 0 when set", nameof(options));
+ }
+ }
+
+ private static bool IsEightDigits(string value)
+ {
+ if (value.Length != 8)
+ {
+ return false;
+ }
+
+ foreach (var c in value)
+ {
+ if (c is < '0' or > '9')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static T Deserialize<T>(byte[] body)
+ where T : class
+ {
+ T? value;
+ try
+ {
+ value = JsonSerializer.Deserialize<T>(body, JsonOptions);
+ }
+ catch (JsonException unreadable)
+ {
+ throw new InvalidResponseException(
+ "the response is not valid JSON — something between you and the API is "
+ + $"answering instead of it: {Preview(body)}",
+ unreadable);
+ }
+
+ return value ?? throw new InvalidResponseException(
+ $"expected a JSON object, got {Preview(body)}");
+ }
+
+ private static string ErrorMessage(byte[] body, string fallback)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(new ReadOnlyMemory<byte>(body));
+ if (document.RootElement.ValueKind == JsonValueKind.Object
+ && document.RootElement.TryGetProperty("mensagem", out var message)
+ && message.ValueKind == JsonValueKind.String
+ && message.GetString() is { Length: > 0 } text)
+ {
+ return text;
+ }
+ }
+ catch (JsonException)
+ {
+ return fallback;
+ }
+
+ return fallback;
+ }
+
+ private static string Preview(byte[] body) =>
+ Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 120));
+
+ /// <summary>
+ /// Turn one CSV record into an address, or null when there is none.
+ /// </summary>
+ /// <remarks>
+ /// The CSV column names are the JSON field names, so the record is routed
+ /// through the same mapping as a native lookup rather than a second,
+ /// drifting copy of it. A record the API filled only the <c>cep</c> column
+ /// of means "not found".
+ /// </remarks>
+ /// <param name="row">the record, by column name</param>
+ /// <returns>the address, or null</returns>
+ private static Address? AddressFromRow(Dictionary<string, string> row)
+ {
+ if (!row.TryGetValue("localidade", out var localidade) || string.IsNullOrWhiteSpace(localidade))
+ {
+ return null;
+ }
+
+ return JsonSerializer.SerializeToElement(row).Deserialize<Address>(JsonOptions);
+ }
+
+ private static string? FirstHeader(HttpResponseMessage response, string name) =>
+ response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null;
+
+ /// <summary>
+ /// One chunk of at most <see cref="MaxBatch"/> CEPs, in a single request.
+ /// </summary>
+ /// <remarks>
+ /// The API guarantees one record 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.
+ /// </remarks>
+ /// <param name="ceps">the chunk</param>
+ /// <param name="cancellationToken">cancels the call</param>
+ /// <returns>one entry per CEP in the chunk, in order</returns>
+ private async Task<List<Address?>> LookupChunkAsync(
+ List<string> ceps,
+ CancellationToken cancellationToken)
+ {
+ var query = "/csv?ceps=" + Uri.EscapeDataString(string.Join(',', ceps));
+ var reply = await RequestAsync(query, OkOnly, cancellationToken).ConfigureAwait(false);
+
+ var rows = new List<Address?>(ceps.Count);
+ foreach (var row in Csv.Parse(Encoding.UTF8.GetString(reply.Body)))
+ {
+ rows.Add(AddressFromRow(row));
+ }
+
+ while (rows.Count < ceps.Count)
+ {
+ rows.Add(null);
+ }
+
+ return rows.Count > ceps.Count ? rows.GetRange(0, ceps.Count) : rows;
+ }
+
+ /// <summary>
+ /// Perform the GET, with retries.
+ /// </summary>
+ /// <remarks>
+ /// <para>
+ /// <i>Definitive</i> error statuses (400, 404) are returned normally, for
+ /// the caller to turn into the right exception — only transient failures
+ /// become exceptions here, and only once the attempts are exhausted.
+ /// </para>
+ /// <para>
+ /// <see cref="CepisClientOptions.TotalTimeout"/> arms its own token, which
+ /// cuts an attempt that is still in flight and any wait between attempts,
+ /// so the whole call really is bounded. That token is also how an abort is
+ /// told apart from a transport failure — asking it, rather than comparing a
+ /// clock against the budget, is what keeps the two from racing. The abort
+ /// does not overwrite the diagnosis: a caller whose budget ran out while
+ /// the API was answering 503 is told about the 503, not about a
+ /// cancellation it did not ask for.
+ /// </para>
+ /// </remarks>
+ /// <param name="path">the path, query included</param>
+ /// <param name="acceptedStatus">statuses to hand back rather than retry</param>
+ /// <param name="cancellationToken">cancels the call</param>
+ /// <returns>the status and the body</returns>
+ private async Task<Reply> RequestAsync(
+ string path,
+ FrozenSet<int> acceptedStatus,
+ CancellationToken cancellationToken)
+ {
+ var uri = _baseUrl + path;
+
+ using var deadline = _options.TotalTimeout is null
+ ? null
+ : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline?.CancelAfter(_options.TotalTimeout!.Value);
+ var callToken = deadline?.Token ?? cancellationToken;
+
+ var attempts = 0;
+ var lastStatus = 0;
+ TimeSpan? lastRetryAfter = null;
+ Exception? lastFailure = null;
+
+ for (var attempt = 1; attempt <= _options.MaxAttempts; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (deadline is { IsCancellationRequested: true })
+ {
+ break;
+ }
+
+ attempts = attempt;
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
+ request.Headers.TryAddWithoutValidation("Accept", AcceptHeader);
+
+ using var attemptCancellation = CancellationTokenSource.CreateLinkedTokenSource(callToken);
+ attemptCancellation.CancelAfter(_options.Timeout);
+
+ try
+ {
+ using var response = await _http
+ .SendAsync(request, HttpCompletionOption.ResponseContentRead, attemptCancellation.Token)
+ .ConfigureAwait(false);
+
+ var status = (int)response.StatusCode;
+ if (acceptedStatus.Contains(status) || !RetriableStatus.Contains(status))
+ {
+ var body = await response.Content
+ .ReadAsByteArrayAsync(attemptCancellation.Token)
+ .ConfigureAwait(false);
+ return new Reply(status, body);
+ }
+
+ lastStatus = status;
+ lastRetryAfter = ParseRetryAfter(FirstHeader(response, "Retry-After"));
+ lastFailure = null;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception unreachable) when (unreachable is HttpRequestException
+ or OperationCanceledException
+ or SocketException
+ or IOException)
+ {
+ if (deadline is { IsCancellationRequested: true })
+ {
+ if (lastStatus == 0)
+ {
+ lastFailure = unreachable;
+ }
+
+ break;
+ }
+
+ lastFailure = unreachable;
+ lastStatus = 0;
+ lastRetryAfter = null;
+ }
+
+ if (attempt == _options.MaxAttempts)
+ {
+ break;
+ }
+
+ if (!await WaitBeforeRetryAsync(attempt, lastRetryAfter, callToken, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ break;
+ }
+ }
+
+ var suffix = $" (after {attempts} attempt(s))";
+ if (lastStatus == 429)
+ {
+ throw new RateLimitedException($"rate limit reached at {uri}{suffix}", lastRetryAfter);
+ }
+
+ if (lastStatus != 0)
+ {
+ throw new ServerErrorException(
+ $"the API answered {lastStatus} at {uri}{suffix}", lastStatus);
+ }
+
+ throw new NetworkException(
+ $"could not reach {uri}{suffix}: {lastFailure?.Message}", lastFailure);
+ }
+
+ /// <summary>
+ /// Wait out the backoff, reporting whether there is still budget to retry.
+ /// </summary>
+ /// <remarks>
+ /// The wait is cut short by <paramref name="callToken"/>, so a
+ /// <see cref="CepisClientOptions.TotalTimeout"/> that expires mid-wait ends
+ /// the call rather than being overrun. A cancellation the caller asked for
+ /// propagates instead.
+ /// </remarks>
+ /// <param name="attempt">which attempt just failed, from one</param>
+ /// <param name="retryAfter">what the server asked for, if anything</param>
+ /// <param name="callToken">the deadline, or the caller's token</param>
+ /// <param name="cancellationToken">the caller's token alone</param>
+ /// <returns>false when the budget is spent</returns>
+ private async Task<bool> WaitBeforeRetryAsync(
+ int attempt,
+ TimeSpan? retryAfter,
+ CancellationToken callToken,
+ CancellationToken cancellationToken)
+ {
+ var wait = Backoff(attempt, retryAfter);
+ if (wait <= TimeSpan.Zero)
+ {
+ return !callToken.IsCancellationRequested;
+ }
+
+ try
+ {
+ await Task.Delay(wait, callToken).ConfigureAwait(false);
+ return true;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return false;
+ }
+ }
+
+ /// <summary>
+ /// How long to wait before the next attempt.
+ /// </summary>
+ /// <remarks>
+ /// The server's <c>Retry-After</c> wins over the local calculation when
+ /// present: it knows when it will be back, we are guessing. It still goes
+ /// through the <see cref="CepisClientOptions.BackoffMax"/> cap. Otherwise
+ /// the wait is drawn from <c>[0, ceiling]</c> rather than being the ceiling.
+ /// </remarks>
+ /// <param name="attempt">which attempt just failed, from one</param>
+ /// <param name="retryAfter">what the server asked for, if anything</param>
+ /// <returns>the wait</returns>
+ internal TimeSpan Backoff(int attempt, TimeSpan? retryAfter)
+ {
+ var ceiling = _options.BackoffMax.TotalMilliseconds;
+ if (retryAfter is { } asked)
+ {
+ return TimeSpan.FromMilliseconds(Math.Min(asked.TotalMilliseconds, ceiling));
+ }
+
+ var exponential = _options.BackoffBase.TotalMilliseconds * Math.Pow(2, Math.Min(attempt - 1, 32));
+ return TimeSpan.FromMilliseconds(Random.Shared.NextDouble() * Math.Min(exponential, ceiling));
+ }
+
+ private readonly record struct Reply(int Status, byte[] Body);
+}
diff --git a/src/Cepis/CepisClientOptions.cs b/src/Cepis/CepisClientOptions.cs
new file mode 100644
index 0000000..ea9234d
--- /dev/null
+++ b/src/Cepis/CepisClientOptions.cs
@@ -0,0 +1,92 @@
+namespace Cepis;
+
+/// <summary>
+/// How a <see cref="CepisClient"/> behaves. Every setting has a default; the
+/// one that usually changes between environments is <see cref="BaseUrl"/>.
+/// </summary>
+/// <example>
+/// <code>
+/// using var client = new CepisClient(new CepisClientOptions
+/// {
+/// BaseUrl = "http://localhost:8080",
+/// TotalTimeout = TimeSpan.FromSeconds(15),
+/// });
+/// </code>
+/// </example>
+public sealed record CepisClientOptions
+{
+ /// <summary>
+ /// Where the API lives. Defaults to <see cref="CepisClient.DefaultUrl"/>.
+ /// </summary>
+ /// <remarks>
+ /// Point it at a local instance or at a test server. A trailing slash is
+ /// stripped when the client is built, so it cannot produce doubled
+ /// separators that some proxies normalise and others answer 404 to.
+ /// </remarks>
+ public string BaseUrl { get; init; } = CepisClient.DefaultUrl;
+
+ /// <summary>
+ /// How long a single attempt may take, connection included. Defaults to ten
+ /// seconds.
+ /// </summary>
+ /// <remarks>
+ /// It is not a cap on the whole call: with three attempts and the waits
+ /// between them, the worst case is roughly three times this plus the
+ /// backoffs. <see cref="TotalTimeout"/> is the cap on the whole call.
+ /// </remarks>
+ public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
+
+ /// <summary>
+ /// How many attempts in total, counting the first. Defaults to three.
+ /// </summary>
+ /// <remarks>
+ /// <c>1</c> disables retrying. The default of <c>3</c> covers the common
+ /// case, one transient failure, without turning a real outage into a long
+ /// wait.
+ /// </remarks>
+ public int MaxAttempts { get; init; } = 3;
+
+ /// <summary>
+ /// The base of the exponential wait between attempts. Defaults to 500
+ /// milliseconds.
+ /// </summary>
+ /// <remarks>
+ /// The actual wait is drawn from <c>[0, base * 2^(n-1)]</c> — 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.
+ /// </remarks>
+ public TimeSpan BackoffBase { get; init; } = TimeSpan.FromMilliseconds(500);
+
+ /// <summary>
+ /// The cap on each individual wait. Defaults to eight seconds.
+ /// </summary>
+ /// <remarks>
+ /// It applies to a server-sent <c>Retry-After</c> too, or a
+ /// <c>Retry-After: 3600</c> from a misconfigured proxy would park the
+ /// caller for an hour.
+ /// </remarks>
+ public TimeSpan BackoffMax { get; init; } = TimeSpan.FromSeconds(8);
+
+ /// <summary>
+ /// The cap on the whole call, attempts and waits included. Unset by
+ /// default.
+ /// </summary>
+ /// <remarks>
+ /// <see langword="null"/> leaves <see cref="MaxAttempts"/> as the only
+ /// bound. Set it in code serving a web request: without it the worst case
+ /// is <see cref="MaxAttempts"/> times <see cref="Timeout"/> plus the sum of
+ /// the backoffs.
+ /// </remarks>
+ public TimeSpan? TotalTimeout { get; init; }
+
+ /// <summary>
+ /// Overrides the <c>User-Agent</c>. <see langword="null"/> uses the
+ /// default.
+ /// </summary>
+ /// <remarks>
+ /// The default identifies the library and its version, which is what lets
+ /// the server diagnose an old client misbehaving.
+ /// </remarks>
+ public string? UserAgent { get; init; }
+}
diff --git a/src/Cepis/CepisException.cs b/src/Cepis/CepisException.cs
new file mode 100644
index 0000000..50af442
--- /dev/null
+++ b/src/Cepis/CepisException.cs
@@ -0,0 +1,39 @@
+namespace Cepis;
+
+/// <summary>
+/// The base of every error this library raises. Catch it to handle any failure.
+/// </summary>
+/// <remarks>
+/// <para>
+/// Below this type the split is by <b>the decision the caller has to make</b>,
+/// not by the HTTP status that caused it.
+/// <see cref="InvalidCepException"/> and <see cref="CepNotFoundException"/> are
+/// problems with the <i>request</i>: retrying is pointless, the same CEP will
+/// fail again. <see cref="NetworkException"/>, <see cref="ServerErrorException"/>
+/// and <see cref="RateLimitedException"/> are problems with <i>transport</i>:
+/// retrying may help, and the client already retried on its own before throwing
+/// any of them.
+/// </para>
+/// <para>
+/// C# allows one base class, so these are not also <see cref="ArgumentException"/>
+/// or <see cref="KeyNotFoundException"/>: one base type that catches every
+/// failure is worth more.
+/// </para>
+/// </remarks>
+public abstract class CepisException : Exception
+{
+ /// <summary>Create the exception with a message.</summary>
+ /// <param name="message">what went wrong</param>
+ protected CepisException(string message)
+ : base(message)
+ {
+ }
+
+ /// <summary>Create the exception with a message and an underlying cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="innerException">the failure underneath, if any</param>
+ protected CepisException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Cepis/Csv.cs b/src/Cepis/Csv.cs
new file mode 100644
index 0000000..ca11558
--- /dev/null
+++ b/src/Cepis/Csv.cs
@@ -0,0 +1,122 @@
+using System.Text;
+
+namespace Cepis;
+
+/// <summary>
+/// An RFC 4180 reader for the batch endpoint.
+/// </summary>
+/// <remarks>
+/// Values may contain commas, quotes and newlines, so they arrive
+/// double-quoted with <c>""</c> escaping; splitting on commas would corrupt
+/// exactly the addresses that need care. A leading byte order mark is dropped,
+/// and CR, LF and CRLF all end a record. The BCL ships no CSV reader, and the
+/// shape this endpoint returns is small enough not to justify a dependency.
+/// </remarks>
+internal static class Csv
+{
+ /// <summary>
+ /// Parse a CSV document whose first record is the header.
+ /// </summary>
+ /// <remarks>
+ /// Each record becomes a dictionary from column name to value. A record
+ /// with fewer fields than the header has the missing ones filled with
+ /// <c>""</c>; extra fields are dropped.
+ /// </remarks>
+ /// <param name="text">the document</param>
+ /// <returns>one dictionary per data record, in order</returns>
+ internal static List<Dictionary<string, string>> Parse(string text)
+ {
+ var records = Split(text);
+ var rows = new List<Dictionary<string, string>>();
+ if (records.Count == 0)
+ {
+ return rows;
+ }
+
+ var header = records[0];
+ for (var r = 1; r < records.Count; r++)
+ {
+ var record = records[r];
+ var row = new Dictionary<string, string>(header.Count, StringComparer.Ordinal);
+ for (var i = 0; i < header.Count; i++)
+ {
+ row[header[i]] = i < record.Count ? record[i] : "";
+ }
+
+ rows.Add(row);
+ }
+
+ return rows;
+ }
+
+ private static List<List<string>> Split(string text)
+ {
+ var records = new List<List<string>>();
+ var record = new List<string>();
+ var field = new StringBuilder();
+ var quoted = false;
+ var pending = false;
+
+ var i = text.Length > 0 && text[0] == '\uFEFF' ? 1 : 0;
+ for (; i < text.Length; i++)
+ {
+ var c = text[i];
+ if (quoted)
+ {
+ if (c != '"')
+ {
+ field.Append(c);
+ }
+ else if (i + 1 < text.Length && text[i + 1] == '"')
+ {
+ field.Append('"');
+ i++;
+ }
+ else
+ {
+ quoted = false;
+ }
+
+ continue;
+ }
+
+ switch (c)
+ {
+ case '"':
+ quoted = true;
+ pending = true;
+ break;
+ case ',':
+ record.Add(field.ToString());
+ field.Clear();
+ pending = true;
+ break;
+ case '\r':
+ case '\n':
+ if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n')
+ {
+ i++;
+ }
+
+ record.Add(field.ToString());
+ field.Clear();
+ records.Add(record);
+ record = [];
+ pending = false;
+ break;
+ default:
+ field.Append(c);
+ pending = true;
+ break;
+ }
+ }
+
+ if (pending || field.Length > 0)
+ {
+ record.Add(field.ToString());
+ records.Add(record);
+ }
+
+ return records;
+ }
+}
diff --git a/src/Cepis/Geo.cs b/src/Cepis/Geo.cs
new file mode 100644
index 0000000..75dfa8b
--- /dev/null
+++ b/src/Cepis/Geo.cs
@@ -0,0 +1,67 @@
+using System.Text.Json.Serialization;
+
+namespace Cepis;
+
+/// <summary>
+/// The CEP's coordinate, when the database has one.
+/// </summary>
+/// <remarks>
+/// <para>
+/// The point is the <b>median</b> 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.
+/// </para>
+/// <para>
+/// Always check <see cref="Precisao"/> before using <see cref="Lat"/> and
+/// <see cref="Lon"/> for distance. <c>"logradouro"</c> means the point is a
+/// stretch of street and <see cref="RaioM"/> lands around 100–150 m — good for
+/// maps and for distance. <c>"localidade"</c> means the CEP <i>is</i> the whole
+/// municipality (a city-wide general CEP) and the point is its centroid, with
+/// <see cref="RaioM"/> running from 7 km to 40 km; computing shipping from that
+/// is wrong by tens of kilometres.
+/// </para>
+/// <para>
+/// <see cref="RaioM"/> is the radius containing 90% of that CEP's addresses. It
+/// exists precisely so that error cannot pass unnoticed.
+/// </para>
+/// <para>
+/// The property names are the API's own, with only the casing adapted:
+/// <c>lat</c>, <c>lon</c>, <c>raio_m</c> and <c>precisao</c> on the wire.
+/// </para>
+/// </remarks>
+public sealed record Geo
+{
+ /// <summary>Latitude in decimal degrees, WGS 84. Wire name <c>lat</c>.</summary>
+ [JsonPropertyName("lat")]
+ public double Lat { get; init; }
+
+ /// <summary>Longitude in decimal degrees, WGS 84. Wire name <c>lon</c>.</summary>
+ [JsonPropertyName("lon")]
+ public double Lon { get; init; }
+
+ /// <summary>
+ /// The radius, in metres, containing 90% of that CEP's addresses. Wire name
+ /// <c>raio_m</c>.
+ /// </summary>
+ [JsonPropertyName("raio_m")]
+ public int RaioM { get; init; }
+
+ /// <summary>
+ /// What the point describes: <c>"logradouro"</c> for a stretch of street,
+ /// <c>"localidade"</c> for a municipality centroid. Wire name
+ /// <c>precisao</c>.
+ /// </summary>
+ [JsonPropertyName("precisao")]
+ public string Precisao { get; init; } = "";
+
+ /// <summary>
+ /// <see langword="true"/> when the coordinate points at a street rather than
+ /// a municipality.
+ /// </summary>
+ /// <remarks>
+ /// Shorthand for <c>Precisao == "logradouro"</c>, which is the check to make
+ /// before using the point to measure distance.
+ /// </remarks>
+ [JsonIgnore]
+ public bool IsPrecise => Precisao == "logradouro";
+}
diff --git a/src/Cepis/GeoConverter.cs b/src/Cepis/GeoConverter.cs
new file mode 100644
index 0000000..f5c78de
--- /dev/null
+++ b/src/Cepis/GeoConverter.cs
@@ -0,0 +1,89 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Cepis;
+
+/// <summary>
+/// Reads the <c>geo</c> block, yielding <see langword="null"/> for anything it
+/// cannot make sense of.
+/// </summary>
+/// <remarks>
+/// A malformed coordinate is not worth losing the whole address over: the rest
+/// of the response is still useful, and an address with no coordinate is a
+/// shape callers already handle, since about 43% of CEPs have none.
+/// </remarks>
+internal sealed class GeoConverter : JsonConverter<Geo?>
+{
+ public override Geo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ var lat = ReadDouble(root, "lat");
+ var lon = ReadDouble(root, "lon");
+ var raio = ReadDouble(root, "raio_m");
+ var precisao = ReadString(root, "precisao");
+
+ if (lat is null || lon is null || raio is null || precisao is null)
+ {
+ return null;
+ }
+
+ return new Geo
+ {
+ Lat = lat.Value,
+ Lon = lon.Value,
+ RaioM = (int)Math.Round(raio.Value),
+ Precisao = precisao,
+ };
+ }
+
+ public override void Write(Utf8JsonWriter writer, Geo? value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteStartObject();
+ writer.WriteNumber("lat", value.Lat);
+ writer.WriteNumber("lon", value.Lon);
+ writer.WriteNumber("raio_m", value.RaioM);
+ writer.WriteString("precisao", value.Precisao);
+ writer.WriteEndObject();
+ }
+
+ private static double? ReadDouble(JsonElement owner, string name)
+ {
+ if (!owner.TryGetProperty(name, out var element))
+ {
+ return null;
+ }
+
+ return element.ValueKind switch
+ {
+ JsonValueKind.Number => element.TryGetDouble(out var number) ? number : null,
+ JsonValueKind.String => double.TryParse(
+ element.GetString(),
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out var parsed)
+ ? parsed
+ : null,
+ _ => null,
+ };
+ }
+
+ private static string? ReadString(JsonElement owner, string name) =>
+ owner.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String
+ ? element.GetString()
+ : null;
+}
diff --git a/src/Cepis/Health.cs b/src/Cepis/Health.cs
new file mode 100644
index 0000000..8101529
--- /dev/null
+++ b/src/Cepis/Health.cs
@@ -0,0 +1,51 @@
+using System.Text.Json.Serialization;
+
+namespace Cepis;
+
+/// <summary>
+/// The response of <c>GET /health</c>.
+/// </summary>
+/// <remarks>
+/// Good for two things: checking that the API answers, and knowing <b>which
+/// version of the database</b> is live. The DNE database changes every two
+/// weeks; <see cref="Dne"/> identifies it.
+/// </remarks>
+public sealed record Health
+{
+ /// <summary>
+ /// <c>"ok"</c> when the API reports itself healthy. Wire name
+ /// <c>status</c>.
+ /// </summary>
+ [JsonPropertyName("status")]
+ public string Status { get; init; } = "";
+
+ /// <summary>
+ /// Identifier of the fortnightly DNE database behind the answers. Wire name
+ /// <c>dne</c>.
+ /// </summary>
+ [JsonPropertyName("dne")]
+ public string Dne { get; init; } = "";
+
+ /// <summary>How many CEPs that database holds. Wire name <c>ceps</c>.</summary>
+ [JsonPropertyName("ceps")]
+ public long Ceps { get; init; }
+
+ /// <summary>
+ /// How many of those CEPs carry a coordinate. Wire name <c>geo</c>.
+ /// </summary>
+ [JsonPropertyName("geo")]
+ public long Geo { get; init; }
+
+ /// <summary>
+ /// When the database was generated, as an ISO 8601 instant. Wire name
+ /// <c>base_gerada_em</c>.
+ /// </summary>
+ [JsonPropertyName("base_gerada_em")]
+ public string BaseGeradaEm { get; init; } = "";
+
+ /// <summary>
+ /// <see langword="true"/> when the API reports itself healthy.
+ /// </summary>
+ [JsonIgnore]
+ public bool Ok => Status == "ok";
+}
diff --git a/src/Cepis/InvalidCepException.cs b/src/Cepis/InvalidCepException.cs
new file mode 100644
index 0000000..8257df7
--- /dev/null
+++ b/src/Cepis/InvalidCepException.cs
@@ -0,0 +1,29 @@
+namespace Cepis;
+
+/// <summary>
+/// The CEP is not eight digits.
+/// </summary>
+/// <remarks>
+/// Usually thrown <b>without</b> a request ever being made: the client checks
+/// the format before going to the network, because a seven-digit CEP is the
+/// caller's bug and spending a round trip for the server to say so wastes both
+/// sides' time. It is also thrown when the API answers 400 to a CEP that passed
+/// local validation, in which case it carries the API's own message.
+/// </remarks>
+public sealed class InvalidCepException : CepisException
+{
+ /// <summary>Create the exception with a message.</summary>
+ /// <param name="message">what went wrong</param>
+ public InvalidCepException(string message)
+ : base(message)
+ {
+ }
+
+ /// <summary>Create the exception with a message and an underlying cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="innerException">the failure underneath</param>
+ public InvalidCepException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Cepis/InvalidResponseException.cs b/src/Cepis/InvalidResponseException.cs
new file mode 100644
index 0000000..e0be6b5
--- /dev/null
+++ b/src/Cepis/InvalidResponseException.cs
@@ -0,0 +1,27 @@
+namespace Cepis;
+
+/// <summary>
+/// A success status came back with a body that cannot be parsed.
+/// </summary>
+/// <remarks>
+/// 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.
+/// </remarks>
+public sealed class InvalidResponseException : CepisException
+{
+ /// <summary>Create the exception with a message.</summary>
+ /// <param name="message">what went wrong</param>
+ public InvalidResponseException(string message)
+ : base(message)
+ {
+ }
+
+ /// <summary>Create the exception with a message and an underlying cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="innerException">the parse failure underneath</param>
+ public InvalidResponseException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Cepis/LenientStringConverter.cs b/src/Cepis/LenientStringConverter.cs
new file mode 100644
index 0000000..f76c917
--- /dev/null
+++ b/src/Cepis/LenientStringConverter.cs
@@ -0,0 +1,57 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Cepis;
+
+/// <summary>
+/// Reads any scalar as a string, and a JSON <c>null</c> as the empty string.
+/// </summary>
+/// <remarks>
+/// The API documents every text field as a string and an absent one as
+/// <c>""</c>, never null. This converter makes a client that meets a
+/// <c>null</c>, or a code sent as a number, degrade to the documented shape
+/// instead of throwing: none of those is worth failing a whole lookup over.
+/// </remarks>
+internal sealed class LenientStringConverter : JsonConverter<string>
+{
+ public override bool HandleNull => true;
+
+ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.String:
+ return reader.GetString() ?? "";
+ case JsonTokenType.Null:
+ return "";
+ case JsonTokenType.True:
+ return "true";
+ case JsonTokenType.False:
+ return "false";
+ case JsonTokenType.Number:
+ var raw = reader.HasValueSequence
+ ? reader.ValueSequence.ToArray()
+ : reader.ValueSpan.ToArray();
+ return Encoding.UTF8.GetString(raw);
+ default:
+ throw new JsonException($"expected a string, got {reader.TokenType}");
+ }
+ }
+
+ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ writer.WriteStringValue(value);
+ }
+
+ public override string ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ reader.GetString() ?? "";
+
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ writer.WritePropertyName(value);
+ }
+}
diff --git a/src/Cepis/NetworkException.cs b/src/Cepis/NetworkException.cs
new file mode 100644
index 0000000..cb7ea77
--- /dev/null
+++ b/src/Cepis/NetworkException.cs
@@ -0,0 +1,27 @@
+namespace Cepis;
+
+/// <summary>
+/// The request never completed: DNS, a refused connection, a timeout, TLS.
+/// </summary>
+/// <remarks>
+/// Thrown only after every configured attempt has failed. A cancellation
+/// requested by the caller is not this — that surfaces as
+/// <see cref="OperationCanceledException"/>, untouched.
+/// </remarks>
+public sealed class NetworkException : CepisException
+{
+ /// <summary>Create the exception with a message.</summary>
+ /// <param name="message">what went wrong</param>
+ public NetworkException(string message)
+ : base(message)
+ {
+ }
+
+ /// <summary>Create the exception with a message and an underlying cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="innerException">the transport failure underneath</param>
+ public NetworkException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Cepis/RateLimitedException.cs b/src/Cepis/RateLimitedException.cs
new file mode 100644
index 0000000..da1e04c
--- /dev/null
+++ b/src/Cepis/RateLimitedException.cs
@@ -0,0 +1,39 @@
+namespace Cepis;
+
+/// <summary>
+/// The API, or the edge in front of it, answered 429 on every attempt.
+/// </summary>
+public sealed class RateLimitedException : CepisException
+{
+ /// <summary>Create the exception with a message and the wait requested.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="retryAfter">
+ /// what the server asked for, or <see langword="null"/> if it did not say
+ /// </param>
+ public RateLimitedException(string message, TimeSpan? retryAfter = null)
+ : base(message)
+ {
+ RetryAfter = retryAfter;
+ }
+
+ /// <summary>Create the exception with a message, the wait and a cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="retryAfter">what the server asked for, if anything</param>
+ /// <param name="innerException">the failure underneath</param>
+ public RateLimitedException(string message, TimeSpan? retryAfter, Exception? innerException)
+ : base(message, innerException)
+ {
+ RetryAfter = retryAfter;
+ }
+
+ /// <summary>
+ /// How long the server asked the caller to wait, or <see langword="null"/>
+ /// if it did not say.
+ /// </summary>
+ /// <remarks>
+ /// The client already honoured this across the attempts it made, capped at
+ /// <see cref="CepisClientOptions.BackoffMax"/>. It is exposed here for
+ /// callers who want to reschedule the work.
+ /// </remarks>
+ public TimeSpan? RetryAfter { get; }
+}
diff --git a/src/Cepis/ServerErrorException.cs b/src/Cepis/ServerErrorException.cs
new file mode 100644
index 0000000..82cea5e
--- /dev/null
+++ b/src/Cepis/ServerErrorException.cs
@@ -0,0 +1,29 @@
+namespace Cepis;
+
+/// <summary>
+/// The API answered 5xx on every attempt.
+/// </summary>
+public sealed class ServerErrorException : CepisException
+{
+ /// <summary>Create the exception with a message and the status seen.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="status">the HTTP status of the last attempt</param>
+ public ServerErrorException(string message, int status)
+ : base(message)
+ {
+ Status = status;
+ }
+
+ /// <summary>Create the exception with a message, the status and a cause.</summary>
+ /// <param name="message">what went wrong</param>
+ /// <param name="status">the HTTP status of the last attempt</param>
+ /// <param name="innerException">the failure underneath</param>
+ public ServerErrorException(string message, int status, Exception? innerException)
+ : base(message, innerException)
+ {
+ Status = status;
+ }
+
+ /// <summary>The HTTP status seen on the last attempt.</summary>
+ public int Status { get; }
+}
diff --git a/tests/Cepis.Tests/BatchTests.cs b/tests/Cepis.Tests/BatchTests.cs
new file mode 100644
index 0000000..8e075f1
--- /dev/null
+++ b/tests/Cepis.Tests/BatchTests.cs
@@ -0,0 +1,120 @@
+using System.Text;
+
+namespace Cepis.Tests;
+
+/// <summary>The batch: order, absences, chunking, and the missing coordinate.</summary>
+public sealed class BatchTests : HttpTestBase
+{
+ [Fact]
+ public async Task KeepsTheRequestedOrderAndLength()
+ {
+ Server.Handle(_ => Reply.Of(200, Fixtures.CsvBatch));
+
+ var result = await Client.LookupManyAsync(["01001000", "99999999", "20040020"]);
+
+ Assert.Equal(3, result.Count);
+ Assert.Equal("São Paulo", result[0]?.Localidade);
+ Assert.Null(result[1]);
+ Assert.Equal("Rio de Janeiro", result[2]?.Localidade);
+ }
+
+ [Fact]
+ public async Task CarriesNoCoordinate()
+ {
+ Server.Handle(_ => Reply.Of(200, Fixtures.CsvBatch));
+
+ var result = await Client.LookupManyAsync(["01001000"]);
+
+ Assert.NotNull(result[0]);
+ Assert.Null(result[0]!.Geo);
+ }
+
+ [Fact]
+ public async Task AMalformedCepIsNullRatherThanAnError()
+ {
+ Server.Handle(call => Reply.Of(200, Csv(call)));
+
+ var result = await Client.LookupManyAsync(["01001000", "oops", "20040020"]);
+
+ Assert.Equal(3, result.Count);
+ Assert.NotNull(result[0]);
+ Assert.Null(result[1]);
+ Assert.NotNull(result[2]);
+ }
+
+ [Fact]
+ public async Task ChunksAboveFiveHundred()
+ {
+ Server.Handle(call => Reply.Of(200, Csv(call)));
+ var ceps = Enumerable.Range(0, 600).Select(i => $"{i:D8}").ToList();
+
+ var result = await Client.LookupManyAsync(ceps);
+
+ Assert.Equal(600, result.Count);
+ Assert.Equal(2, Server.Calls.Count);
+ Assert.Equal(CepisClient.MaxBatch, CountCeps(Server.Calls[0]));
+ Assert.Equal(100, CountCeps(Server.Calls[1]));
+ Assert.All(result, address => Assert.NotNull(address));
+ }
+
+ [Fact]
+ public async Task ExactlyFiveHundredIsOneRequest()
+ {
+ Server.Handle(call => Reply.Of(200, Csv(call)));
+ var ceps = Enumerable.Range(0, CepisClient.MaxBatch).Select(i => $"{i:D8}").ToList();
+
+ var result = await Client.LookupManyAsync(ceps);
+
+ Assert.Equal(CepisClient.MaxBatch, result.Count);
+ Assert.Single(Server.Calls);
+ }
+
+ [Fact]
+ public async Task AShortResponseIsPaddedRatherThanShifted()
+ {
+ Server.Handle(_ => Reply.Of(200, Fixtures.CsvHeader + "\n" + Fixtures.CsvRow("01001000") + "\n"));
+
+ var result = await Client.LookupManyAsync(["01001000", "20040020", "30140071"]);
+
+ Assert.Equal(3, result.Count);
+ Assert.Equal("01001000", result[0]?.Cep);
+ Assert.Null(result[1]);
+ Assert.Null(result[2]);
+ }
+
+ [Fact]
+ public async Task AnEmptyInputMakesNoRequest()
+ {
+ var result = await Client.LookupManyAsync([]);
+
+ Assert.Empty(result);
+ Assert.Empty(Server.Calls);
+ }
+
+ [Fact]
+ public async Task ValuesWithCommasSurviveTheParse()
+ {
+ Server.Handle(_ => Reply.Of(200, Fixtures.CsvHeader + "\n"
+ + "\"01001000\",\"Praça da Sé, lado ímpar\",,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107\n"));
+
+ var result = await Client.LookupManyAsync(["01001000"]);
+
+ Assert.Equal("Praça da Sé, lado ímpar", result[0]?.Logradouro);
+ }
+
+ private static string Csv(Call call)
+ {
+ var body = new StringBuilder(Fixtures.CsvHeader);
+ foreach (var cep in Requested(call))
+ {
+ body.Append('\n').Append(cep.Length == 8 ? Fixtures.CsvRow(cep) : cep + ",,,,,,,,,,,,");
+ }
+
+ return body.Append('\n').ToString();
+ }
+
+ private static string[] Requested(Call call) =>
+ (call.Ceps ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ private static int CountCeps(Call call) => Requested(call).Length;
+}
diff --git a/tests/Cepis.Tests/Cepis.Tests.csproj b/tests/Cepis.Tests/Cepis.Tests.csproj
new file mode 100644
index 0000000..40faa78
--- /dev/null
+++ b/tests/Cepis.Tests/Cepis.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <LangVersion>12.0</LangVersion>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
+ <PackageReference Include="xunit" Version="2.5.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Using Include="Xunit" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Cepis\Cepis.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/tests/Cepis.Tests/ConfigurationTests.cs b/tests/Cepis.Tests/ConfigurationTests.cs
new file mode 100644
index 0000000..2f6fab9
--- /dev/null
+++ b/tests/Cepis.Tests/ConfigurationTests.cs
@@ -0,0 +1,80 @@
+namespace Cepis.Tests;
+
+/// <summary>What the client accepts as configuration, and what it refuses.</summary>
+public sealed class ConfigurationTests : HttpTestBase
+{
+ [Fact]
+ public void TheDefaultsAreTheDocumentedOnes()
+ {
+ var options = new CepisClientOptions();
+
+ Assert.Equal(CepisClient.DefaultUrl, options.BaseUrl);
+ Assert.Equal(TimeSpan.FromSeconds(10), options.Timeout);
+ Assert.Equal(3, options.MaxAttempts);
+ Assert.Equal(TimeSpan.FromMilliseconds(500), options.BackoffBase);
+ Assert.Equal(TimeSpan.FromSeconds(8), options.BackoffMax);
+ Assert.Null(options.TotalTimeout);
+ Assert.Null(options.UserAgent);
+ }
+
+ [Fact]
+ public void TheDefaultUserAgentIdentifiesTheLibrary()
+ {
+ using var client = new CepisClient();
+
+ Assert.Equal($"cepis-csharp/{CepisClient.Version} (+https://cep.is)", client.UserAgent);
+ }
+
+ [Fact]
+ public async Task ATrailingSlashCannotDoubleTheSeparator()
+ {
+ using var client = new CepisClient(new CepisClientOptions { BaseUrl = Server.Url + "///" });
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress));
+
+ await client.LookupAsync("01001000");
+
+ Assert.Equal(Server.Url, client.BaseUrl);
+ Assert.Equal("/01001000", Assert.Single(Server.Calls).Path);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxAttemptsBelowOneIsRefused(int attempts) =>
+ Assert.Throws<ArgumentException>(
+ () => new CepisClient(new CepisClientOptions { MaxAttempts = attempts }));
+
+ [Fact]
+ public void ATimeoutOfZeroIsRefused() =>
+ Assert.Throws<ArgumentException>(
+ () => new CepisClient(new CepisClientOptions { Timeout = TimeSpan.Zero }));
+
+ [Fact]
+ public void AnEmptyBaseUrlIsRefused() =>
+ Assert.Throws<ArgumentException>(
+ () => new CepisClient(new CepisClientOptions { BaseUrl = " " }));
+
+ [Fact]
+ public void ATotalTimeoutOfZeroIsRefused() =>
+ Assert.Throws<ArgumentException>(
+ () => new CepisClient(new CepisClientOptions { TotalTimeout = TimeSpan.Zero }));
+
+ [Fact]
+ public async Task AnInjectedHttpClientIsUsedAndNotDisposed()
+ {
+ using var http = new HttpClient();
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress));
+
+ using (var client = new CepisClient(http, new CepisClientOptions { BaseUrl = Server.Url }))
+ {
+ await client.LookupAsync("01001000");
+ }
+
+ Assert.Equal(1, Server.Count("/01001000"));
+ await http.GetAsync(new Uri(Server.Url + "/01001000"));
+ }
+
+ [Fact]
+ public void TheSharedDefaultPointsAtTheRealApi() =>
+ Assert.Equal(CepisClient.DefaultUrl, CepisClient.Default.BaseUrl);
+}
diff --git a/tests/Cepis.Tests/CsvTests.cs b/tests/Cepis.Tests/CsvTests.cs
new file mode 100644
index 0000000..97a353f
--- /dev/null
+++ b/tests/Cepis.Tests/CsvTests.cs
@@ -0,0 +1,79 @@
+namespace Cepis.Tests;
+
+/// <summary>The CSV reader behind the batch endpoint.</summary>
+public sealed class CsvTests
+{
+ [Fact]
+ public void ReadsTheHeaderAndOneRecord()
+ {
+ var rows = Csv.Parse("a,b,c\n1,2,3\n");
+
+ var row = Assert.Single(rows);
+ Assert.Equal("1", row["a"]);
+ Assert.Equal("2", row["b"]);
+ Assert.Equal("3", row["c"]);
+ }
+
+ [Fact]
+ public void AQuotedValueMayContainACommaAndANewline()
+ {
+ var rows = Csv.Parse("a,b\n\"Praça da Sé, lado ímpar\",\"two\nlines\"\n");
+
+ var row = Assert.Single(rows);
+ Assert.Equal("Praça da Sé, lado ímpar", row["a"]);
+ Assert.Equal("two\nlines", row["b"]);
+ }
+
+ [Fact]
+ public void DoubledQuotesAreOneQuote()
+ {
+ var rows = Csv.Parse("a\n\"say \"\"hi\"\"\"\n");
+
+ Assert.Equal("say \"hi\"", Assert.Single(rows)["a"]);
+ }
+
+ [Theory]
+ [InlineData("a,b\n1,2\n")]
+ [InlineData("a,b\r\n1,2\r\n")]
+ [InlineData("a,b\r1,2\r")]
+ [InlineData("a,b\n1,2")]
+ public void EveryLineEndingWorks(string text)
+ {
+ var rows = Csv.Parse(text);
+
+ Assert.Equal("1", Assert.Single(rows)["a"]);
+ }
+
+ [Fact]
+ public void ALeadingByteOrderMarkIsDropped()
+ {
+ var rows = Csv.Parse("\uFEFFa,b\n1,2\n");
+
+ Assert.Equal("1", Assert.Single(rows)["a"]);
+ }
+
+ [Fact]
+ public void AnEmptyValueStaysEmpty()
+ {
+ var rows = Csv.Parse("a,b,c\n1,,3\n");
+
+ Assert.Equal("", Assert.Single(rows)["b"]);
+ }
+
+ [Fact]
+ public void AShortRecordIsFilledOut()
+ {
+ var rows = Csv.Parse("a,b,c\n1\n");
+
+ var row = Assert.Single(rows);
+ Assert.Equal("1", row["a"]);
+ Assert.Equal("", row["b"]);
+ Assert.Equal("", row["c"]);
+ }
+
+ [Fact]
+ public void AHeaderAloneYieldsNoRecords() => Assert.Empty(Csv.Parse("a,b,c\n"));
+
+ [Fact]
+ public void AnEmptyDocumentYieldsNoRecords() => Assert.Empty(Csv.Parse(""));
+}
diff --git a/tests/Cepis.Tests/FakeServer.cs b/tests/Cepis.Tests/FakeServer.cs
new file mode 100644
index 0000000..1bd4d60
--- /dev/null
+++ b/tests/Cepis.Tests/FakeServer.cs
@@ -0,0 +1,168 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace Cepis.Tests;
+
+/// <summary>One canned response.</summary>
+public sealed record Reply(int Status, string Body, IReadOnlyDictionary<string, string>? Headers = null)
+{
+ /// <summary>A reply with no extra headers.</summary>
+ public static Reply Of(int status, string body) => new(status, body);
+
+ /// <summary>A reply carrying one extra header.</summary>
+ public static Reply Of(int status, string body, string header, string value) =>
+ new(status, body, new Dictionary<string, string>(StringComparer.Ordinal) { [header] = value });
+}
+
+/// <summary>One request the server saw.</summary>
+public sealed record Call(string Path, string? Ceps);
+
+/// <summary>
+/// A throwaway HTTP server on a free loopback port, with programmable routes and
+/// a request log.
+/// </summary>
+/// <remarks>
+/// The tests point a real <see cref="CepisClient"/> at it, so the code path they
+/// exercise is the one that runs in production, request construction and error
+/// body reading included. It doubles as the test that <c>BaseUrl</c> works: if
+/// it did not, none of the suite would run.
+/// </remarks>
+public sealed class FakeServer : IDisposable
+{
+ private readonly HttpListener _listener = new();
+ private readonly Dictionary<string, Queue<Reply>> _routes = new(StringComparer.Ordinal);
+ private readonly List<Call> _calls = [];
+ private readonly object _gate = new();
+ private Func<Call, Reply>? _handler;
+
+ /// <summary>Start the server on a free port.</summary>
+ public FakeServer()
+ {
+ var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+
+ Url = $"http://127.0.0.1:{port}";
+ _listener.Prefixes.Add($"http://127.0.0.1:{port}/");
+ _listener.Start();
+ _ = Task.Run(LoopAsync);
+ }
+
+ /// <summary>Where the server listens, without a trailing slash.</summary>
+ public string Url { get; }
+
+ /// <summary>Every request seen so far, in order.</summary>
+ public IReadOnlyList<Call> Calls
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return [.. _calls];
+ }
+ }
+ }
+
+ /// <summary>
+ /// Register the replies for a path. Each request consumes the next one; the
+ /// last repeats once the list runs out, which is how retrying is tested.
+ /// </summary>
+ public void Respond(string path, params Reply[] replies)
+ {
+ lock (_gate)
+ {
+ _routes[path] = new Queue<Reply>(replies);
+ }
+ }
+
+ /// <summary>Answer every request with this function instead of the routes.</summary>
+ public void Handle(Func<Call, Reply> handler)
+ {
+ lock (_gate)
+ {
+ _handler = handler;
+ }
+ }
+
+ /// <summary>How many requests so far went to that path.</summary>
+ public int Count(string path)
+ {
+ lock (_gate)
+ {
+ return _calls.Count(call => call.Path == path);
+ }
+ }
+
+ /// <summary>Stop the server.</summary>
+ public void Dispose() => _listener.Close();
+
+ private async Task LoopAsync()
+ {
+ while (_listener.IsListening)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await _listener.GetContextAsync().ConfigureAwait(false);
+ }
+ catch (Exception stopped) when (stopped is HttpListenerException
+ or ObjectDisposedException
+ or InvalidOperationException)
+ {
+ return;
+ }
+
+ _ = Task.Run(() => Serve(context));
+ }
+ }
+
+ private void Serve(HttpListenerContext context)
+ {
+ var call = new Call(context.Request.Url!.AbsolutePath, context.Request.QueryString["ceps"]);
+ var reply = Next(call);
+ var body = Encoding.UTF8.GetBytes(reply.Body);
+
+ try
+ {
+ context.Response.StatusCode = reply.Status;
+ if (reply.Headers is not null)
+ {
+ foreach (var (name, value) in reply.Headers)
+ {
+ context.Response.Headers[name] = value;
+ }
+ }
+
+ context.Response.ContentLength64 = body.Length;
+ context.Response.OutputStream.Write(body);
+ context.Response.OutputStream.Close();
+ }
+ catch (Exception gone) when (gone is HttpListenerException or ObjectDisposedException or IOException)
+ {
+ }
+ }
+
+ private Reply Next(Call call)
+ {
+ Func<Call, Reply> handler;
+ lock (_gate)
+ {
+ _calls.Add(call);
+ if (_handler is null)
+ {
+ if (!_routes.TryGetValue(call.Path, out var queue) || queue.Count == 0)
+ {
+ return Reply.Of(404, Fixtures.NotFound);
+ }
+
+ return queue.Count > 1 ? queue.Dequeue() : queue.Peek();
+ }
+
+ handler = _handler;
+ }
+
+ return handler(call);
+ }
+}
diff --git a/tests/Cepis.Tests/Fixtures.cs b/tests/Cepis.Tests/Fixtures.cs
new file mode 100644
index 0000000..648f881
--- /dev/null
+++ b/tests/Cepis.Tests/Fixtures.cs
@@ -0,0 +1,69 @@
+namespace Cepis.Tests;
+
+/// <summary>Bodies the API really returns, copied from it.</summary>
+public static class Fixtures
+{
+ /// <summary>A CEP with a street-precision coordinate.</summary>
+ public const string SeAddress = """
+ {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",
+ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",
+ "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",
+ "ddd":"11","siafi":"7107",
+ "geo":{"lat":-23.567202,"lon":-46.649659,"raio_m":1084,"precisao":"logradouro"}}
+ """;
+
+ /// <summary>A CEP the database has no coordinate for.</summary>
+ public const string NoGeoAddress = """
+ {"cep":"20040020","logradouro":"Avenida Rio Branco","complemento":"",
+ "unidade":"","bairro":"Centro","localidade":"Rio de Janeiro","uf":"RJ",
+ "estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3304557","gia":"",
+ "ddd":"21","siafi":"6001"}
+ """;
+
+ /// <summary>A coordinate whose numbers cannot be read.</summary>
+ public const string BrokenGeoAddress = """
+ {"cep":"01001000","logradouro":"Praça da Sé","complemento":"","unidade":"",
+ "bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo",
+ "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107",
+ "geo":{"lat":"nowhere","precisao":"logradouro"}}
+ """;
+
+ /// <summary>A response carrying a field this library does not model.</summary>
+ public const string FutureAddress = """
+ {"cep":"01001000","logradouro":"Praça da Sé","complemento":"","unidade":"",
+ "bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo",
+ "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107",
+ "cep_pai":"01000000"}
+ """;
+
+ /// <summary>The 404 body.</summary>
+ public const string NotFound =
+ """{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}""";
+
+ /// <summary>The 400 body.</summary>
+ public const string InvalidCep =
+ """{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}""";
+
+ /// <summary>The 500 body.</summary>
+ public const string InternalError = """{"erro":"interno","mensagem":"falha interna"}""";
+
+ /// <summary>A healthy API.</summary>
+ public const string Healthy = """
+ {"status":"ok","dne":"26071","ceps":1596168,"geo":894489,
+ "base_gerada_em":"2026-07-24T03:48:28Z"}
+ """;
+
+ /// <summary>The batch header row.</summary>
+ public const string CsvHeader =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi";
+
+ /// <summary>Three CEPs, the middle one absent from the database.</summary>
+ public const string CsvBatch = CsvHeader + "\n"
+ + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107\n"
+ + "99999999,,,,,,,,,,,,\n"
+ + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001\n";
+
+ /// <summary>One data row for a CEP, in the batch's column order.</summary>
+ public static string CsvRow(string cep) =>
+ $"{cep},Rua Teste,,,Centro,Cidade,SP,São Paulo,Sudeste,3550308,,11,7107";
+}
diff --git a/tests/Cepis.Tests/HealthTests.cs b/tests/Cepis.Tests/HealthTests.cs
new file mode 100644
index 0000000..2855d72
--- /dev/null
+++ b/tests/Cepis.Tests/HealthTests.cs
@@ -0,0 +1,46 @@
+namespace Cepis.Tests;
+
+/// <summary>The health endpoint, which reports rather than throws.</summary>
+public sealed class HealthTests : HttpTestBase
+{
+ [Fact]
+ public async Task ReadsAHealthyApi()
+ {
+ Server.Respond("/health", Reply.Of(200, Fixtures.Healthy));
+
+ var health = await Client.HealthAsync();
+
+ Assert.True(health.Ok);
+ Assert.Equal("ok", health.Status);
+ Assert.Equal("26071", health.Dne);
+ Assert.Equal(1596168, health.Ceps);
+ Assert.Equal(894489, health.Geo);
+ Assert.Equal("2026-07-24T03:48:28Z", health.BaseGeradaEm);
+ }
+
+ [Fact]
+ public async Task ADegradedApiReportsRatherThanThrows()
+ {
+ Server.Respond("/health", Reply.Of(503,
+ """{"status":"degradado","dne":"26071","ceps":0,"geo":0,"base_gerada_em":""}"""));
+
+ var health = await Client.HealthAsync();
+
+ Assert.False(health.Ok);
+ Assert.Equal("degradado", health.Status);
+ Assert.Equal(1, Server.Count("/health"));
+ }
+
+ [Fact]
+ public async Task AnUnreachableApiStillThrows()
+ {
+ using var unreachable = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = "http://127.0.0.1:1",
+ MaxAttempts = 1,
+ Timeout = TimeSpan.FromSeconds(30),
+ });
+
+ await Assert.ThrowsAsync<NetworkException>(() => unreachable.HealthAsync());
+ }
+}
diff --git a/tests/Cepis.Tests/HttpTestBase.cs b/tests/Cepis.Tests/HttpTestBase.cs
new file mode 100644
index 0000000..115ceef
--- /dev/null
+++ b/tests/Cepis.Tests/HttpTestBase.cs
@@ -0,0 +1,33 @@
+namespace Cepis.Tests;
+
+/// <summary>A fake API and a real client pointed at it, torn down per test.</summary>
+public abstract class HttpTestBase : IDisposable
+{
+ /// <summary>The fake API.</summary>
+ protected FakeServer Server { get; } = new();
+
+ /// <summary>
+ /// A client whose backoff is shortened so the suite stays fast, and whose
+ /// per-attempt timeout is deliberately generous: a loaded machine must not
+ /// be able to turn a passing test into a spurious network failure.
+ /// </summary>
+ protected CepisClient Client { get; }
+
+ /// <summary>Start the server and point a client at it.</summary>
+ protected HttpTestBase() =>
+ Client = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ Timeout = TimeSpan.FromSeconds(30),
+ BackoffBase = TimeSpan.FromMilliseconds(1),
+ BackoffMax = TimeSpan.FromMilliseconds(20),
+ });
+
+ /// <summary>Stop the server and release the client.</summary>
+ public void Dispose()
+ {
+ Client.Dispose();
+ Server.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/tests/Cepis.Tests/LookupTests.cs b/tests/Cepis.Tests/LookupTests.cs
new file mode 100644
index 0000000..ad3e2bf
--- /dev/null
+++ b/tests/Cepis.Tests/LookupTests.cs
@@ -0,0 +1,168 @@
+namespace Cepis.Tests;
+
+/// <summary>A single lookup: the happy path, the coordinate, and the refusals.</summary>
+public sealed class LookupTests : HttpTestBase
+{
+ [Fact]
+ public async Task ReadsEveryField()
+ {
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress));
+
+ var address = await Client.LookupAsync("01001-000");
+
+ Assert.Equal("01001000", address.Cep);
+ Assert.Equal("01001-000", address.FormattedCep);
+ Assert.Equal("Praça da Sé", address.Logradouro);
+ Assert.Equal("lado ímpar", address.Complemento);
+ Assert.Equal("", address.Unidade);
+ Assert.Equal("Sé", address.Bairro);
+ Assert.Equal("São Paulo", address.Localidade);
+ Assert.Equal("SP", address.UF);
+ Assert.Equal("São Paulo", address.Estado);
+ Assert.Equal("Sudeste", address.Regiao);
+ Assert.Equal("3550308", address.Ibge);
+ Assert.Equal("1004", address.Gia);
+ Assert.Equal("11", address.Ddd);
+ Assert.Equal("7107", address.Siafi);
+ }
+
+ [Fact]
+ public async Task TheMaskIsStrippedBeforeTheRequest()
+ {
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress));
+
+ await Client.LookupAsync("01001-000");
+
+ Assert.Equal("/01001000", Assert.Single(Server.Calls).Path);
+ }
+
+ [Fact]
+ public async Task ReadsTheCoordinate()
+ {
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress));
+
+ var address = await Client.LookupAsync("01001000");
+
+ var geo = address.Geo;
+ Assert.NotNull(geo);
+ Assert.Equal(-23.567202, geo.Lat, 6);
+ Assert.Equal(-46.649659, geo.Lon, 6);
+ Assert.Equal(1084, geo.RaioM);
+ Assert.Equal("logradouro", geo.Precisao);
+ Assert.True(geo.IsPrecise);
+ }
+
+ [Fact]
+ public async Task AnAbsentCoordinateIsNull()
+ {
+ Server.Respond("/20040020", Reply.Of(200, Fixtures.NoGeoAddress));
+
+ var address = await Client.LookupAsync("20040020");
+
+ Assert.Null(address.Geo);
+ Assert.Equal("Rio de Janeiro", address.Localidade);
+ }
+
+ [Fact]
+ public async Task AMalformedCoordinateDoesNotLoseTheAddress()
+ {
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.BrokenGeoAddress));
+
+ var address = await Client.LookupAsync("01001000");
+
+ Assert.Null(address.Geo);
+ Assert.Equal("São Paulo", address.Localidade);
+ }
+
+ [Fact]
+ public async Task ACoordinateOfTheWrongShapeIsDropped()
+ {
+ Server.Respond("/01001000", Reply.Of(200, """{"cep":"01001000","localidade":"X","geo":[]}"""));
+
+ var address = await Client.LookupAsync("01001000");
+
+ Assert.Null(address.Geo);
+ Assert.Equal("X", address.Localidade);
+ }
+
+ [Fact]
+ public async Task AFieldThisLibraryDoesNotKnowIsKept()
+ {
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.FutureAddress));
+
+ var address = await Client.LookupAsync("01001000");
+
+ Assert.Equal("São Paulo", address.Localidade);
+ var extra = address.Extra;
+ Assert.NotNull(extra);
+ Assert.Equal("01000000", extra["cep_pai"].GetString());
+ }
+
+ [Fact]
+ public async Task AMissingCepThrows()
+ {
+ Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound));
+
+ var failure = await Assert.ThrowsAsync<CepNotFoundException>(
+ () => Client.LookupAsync("99999999"));
+
+ Assert.Contains("99999999", failure.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task A404IsNeverRetried()
+ {
+ Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound));
+
+ await Assert.ThrowsAsync<CepNotFoundException>(() => Client.LookupAsync("99999999"));
+
+ Assert.Equal(1, Server.Count("/99999999"));
+ }
+
+ [Fact]
+ public async Task A400CarriesTheApiMessage()
+ {
+ Server.Respond("/00000000", Reply.Of(400, Fixtures.InvalidCep));
+
+ var failure = await Assert.ThrowsAsync<InvalidCepException>(
+ () => Client.LookupAsync("00000000"));
+
+ Assert.Equal("CEP deve ter 8 dígitos", failure.Message);
+ Assert.Equal(1, Server.Count("/00000000"));
+ }
+
+ [Fact]
+ public async Task AMalformedCepCostsNoRequest()
+ {
+ await Assert.ThrowsAsync<InvalidCepException>(() => Client.LookupAsync("1234567"));
+
+ Assert.Empty(Server.Calls);
+ }
+
+ [Fact]
+ public async Task AnUnintelligibleBodyIsDiagnosed()
+ {
+ Server.Respond("/01001000", Reply.Of(200, "<html><body>captive portal</body></html>"));
+
+ await Assert.ThrowsAsync<InvalidResponseException>(() => Client.LookupAsync("01001000"));
+ }
+
+ [Fact]
+ public async Task AJsonBodyThatIsNotAnObjectIsDiagnosed()
+ {
+ Server.Respond("/01001000", Reply.Of(200, "null"));
+
+ await Assert.ThrowsAsync<InvalidResponseException>(() => Client.LookupAsync("01001000"));
+ }
+
+ [Fact]
+ public async Task ACancelledCallDoesNotBecomeANetworkError()
+ {
+ Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress));
+ using var cancellation = new CancellationTokenSource();
+ await cancellation.CancelAsync();
+
+ await Assert.ThrowsAnyAsync<OperationCanceledException>(
+ () => Client.LookupAsync("01001000", cancellation.Token));
+ }
+}
diff --git a/tests/Cepis.Tests/ModelTests.cs b/tests/Cepis.Tests/ModelTests.cs
new file mode 100644
index 0000000..7d9d0d6
--- /dev/null
+++ b/tests/Cepis.Tests/ModelTests.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+
+namespace Cepis.Tests;
+
+/// <summary>The data objects on their own, with no server involved.</summary>
+public sealed class ModelTests
+{
+ [Fact]
+ public void EveryFieldDefaultsToTheEmptyString()
+ {
+ var address = new Address();
+
+ Assert.Equal("", address.Cep);
+ Assert.Equal("", address.Logradouro);
+ Assert.Equal("", address.Siafi);
+ Assert.Null(address.Geo);
+ }
+
+ [Fact]
+ public void FormattedCepAddsTheMask() =>
+ Assert.Equal("01001-000", new Address { Cep = "01001000" }.FormattedCep);
+
+ [Fact]
+ public void FormattedCepLeavesAnythingElseAlone() =>
+ Assert.Equal("123", new Address { Cep = "123" }.FormattedCep);
+
+ [Fact]
+ public void TwoAddressesForTheSamePlaceAreEqual()
+ {
+ var one = new Address { Cep = "01001000", Localidade = "São Paulo" };
+ var other = new Address { Cep = "01001000", Localidade = "São Paulo" };
+
+ Assert.Equal(one, other);
+ Assert.Equal(one.GetHashCode(), other.GetHashCode());
+ }
+
+ [Fact]
+ public void EqualityIgnoresFieldsThisLibraryDoesNotModel()
+ {
+ var plain = new Address { Cep = "01001000" };
+ var enriched = plain with
+ {
+ Extra = new Dictionary<string, JsonElement>(StringComparer.Ordinal)
+ {
+ ["cep_pai"] = JsonDocument.Parse("\"01000000\"").RootElement,
+ },
+ };
+
+ Assert.Equal(plain, enriched);
+ Assert.DoesNotContain("cep_pai", enriched.ToString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheCoordinateIsPreciseOnlyForAStreet()
+ {
+ Assert.True(new Geo { Precisao = "logradouro" }.IsPrecise);
+ Assert.False(new Geo { Precisao = "localidade" }.IsPrecise);
+ Assert.False(new Geo().IsPrecise);
+ }
+
+ [Fact]
+ public void HealthIsOkOnlyForTheDocumentedStatus()
+ {
+ Assert.True(new Health { Status = "ok" }.Ok);
+ Assert.False(new Health { Status = "degradado" }.Ok);
+ Assert.False(new Health().Ok);
+ }
+}
diff --git a/tests/Cepis.Tests/NormalizeTests.cs b/tests/Cepis.Tests/NormalizeTests.cs
new file mode 100644
index 0000000..3ff60cb
--- /dev/null
+++ b/tests/Cepis.Tests/NormalizeTests.cs
@@ -0,0 +1,35 @@
+namespace Cepis.Tests;
+
+/// <summary>Local validation, which happens before any request.</summary>
+public sealed class NormalizeTests
+{
+ [Theory]
+ [InlineData("01001000", "01001000")]
+ [InlineData("01001-000", "01001000")]
+ [InlineData(" 01001-000 ", "01001000")]
+ [InlineData("01.001-000", "01001000")]
+ [InlineData("01 001 000", "01001000")]
+ public void AcceptsTheMask(string input, string expected) =>
+ Assert.Equal(expected, CepisClient.NormalizeCep(input));
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("0100100")]
+ [InlineData("010010000")]
+ [InlineData("0100100a")]
+ [InlineData("abcdefgh")]
+ [InlineData("01001-00")]
+ public void RejectsAnythingElse(string input) =>
+ Assert.Throws<InvalidCepException>(() => CepisClient.NormalizeCep(input));
+
+ [Fact]
+ public void RejectsNull() =>
+ Assert.Throws<InvalidCepException>(() => CepisClient.NormalizeCep(null!));
+
+ [Fact]
+ public void TheMessageCarriesWhatWasGiven()
+ {
+ var failure = Assert.Throws<InvalidCepException>(() => CepisClient.NormalizeCep("123"));
+ Assert.Contains("123", failure.Message, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Cepis.Tests/RetryTests.cs b/tests/Cepis.Tests/RetryTests.cs
new file mode 100644
index 0000000..2114a12
--- /dev/null
+++ b/tests/Cepis.Tests/RetryTests.cs
@@ -0,0 +1,249 @@
+namespace Cepis.Tests;
+
+/// <summary>The retry policy: what is retried, how long it waits, what it never retries.</summary>
+public sealed class RetryTests : HttpTestBase
+{
+ [Fact]
+ public async Task ATransientFiveHundredIsRetried()
+ {
+ Server.Respond("/01001000",
+ Reply.Of(500, Fixtures.InternalError),
+ Reply.Of(200, Fixtures.SeAddress));
+
+ var address = await Client.LookupAsync("01001000");
+
+ Assert.Equal("São Paulo", address.Localidade);
+ Assert.Equal(2, Server.Count("/01001000"));
+ }
+
+ [Theory]
+ [InlineData(500)]
+ [InlineData(502)]
+ [InlineData(503)]
+ [InlineData(504)]
+ public async Task EveryTransientStatusIsRetried(int status)
+ {
+ Server.Respond("/01001000", Reply.Of(status, Fixtures.InternalError), Reply.Of(200, Fixtures.SeAddress));
+
+ await Client.LookupAsync("01001000");
+
+ Assert.Equal(2, Server.Count("/01001000"));
+ }
+
+ [Fact]
+ public async Task ThreeAttemptsIsTheDefault()
+ {
+ Server.Respond("/01001000", Reply.Of(503, Fixtures.InternalError));
+
+ var failure = await Assert.ThrowsAsync<ServerErrorException>(
+ () => Client.LookupAsync("01001000"));
+
+ Assert.Equal(503, failure.Status);
+ Assert.Equal(3, Server.Count("/01001000"));
+ }
+
+ [Fact]
+ public async Task OneAttemptDisablesRetrying()
+ {
+ using var single = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ MaxAttempts = 1,
+ });
+ Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError));
+
+ await Assert.ThrowsAsync<ServerErrorException>(() => single.LookupAsync("01001000"));
+
+ Assert.Equal(1, Server.Count("/01001000"));
+ }
+
+ [Fact]
+ public async Task A429BecomesRateLimitedAndCarriesRetryAfter()
+ {
+ Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "7"));
+
+ var failure = await Assert.ThrowsAsync<RateLimitedException>(
+ () => Client.LookupAsync("01001000"));
+
+ Assert.Equal(TimeSpan.FromSeconds(7), failure.RetryAfter);
+ }
+
+ [Fact]
+ public async Task A429WithoutRetryAfterLeavesNothingToRescheduleWith()
+ {
+ Server.Respond("/01001000", Reply.Of(429, "{}"));
+
+ var failure = await Assert.ThrowsAsync<RateLimitedException>(
+ () => Client.LookupAsync("01001000"));
+
+ Assert.Null(failure.RetryAfter);
+ }
+
+ [Fact]
+ public void AnAbsurdRetryAfterIsCappedAtBackoffMax()
+ {
+ using var capped = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ BackoffMax = TimeSpan.FromMilliseconds(100),
+ });
+
+ Assert.Equal(TimeSpan.FromMilliseconds(100), capped.Backoff(1, TimeSpan.FromHours(1)));
+ }
+
+ [Fact]
+ public void ARetryAfterUnderTheCapIsHonouredAsAsked()
+ {
+ using var client = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ BackoffMax = TimeSpan.FromSeconds(8),
+ });
+
+ Assert.Equal(TimeSpan.FromSeconds(2), client.Backoff(1, TimeSpan.FromSeconds(2)));
+ }
+
+ [Fact]
+ public void TheBackoffGrowsExponentiallyAndNeverPassesTheCap()
+ {
+ using var client = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ BackoffBase = TimeSpan.FromMilliseconds(100),
+ BackoffMax = TimeSpan.FromMilliseconds(800),
+ });
+
+ for (var attempt = 1; attempt <= 8; attempt++)
+ {
+ var ceiling = TimeSpan.FromMilliseconds(Math.Min(100 * Math.Pow(2, attempt - 1), 800));
+ for (var draw = 0; draw < 200; draw++)
+ {
+ Assert.InRange(client.Backoff(attempt, null), TimeSpan.Zero, ceiling);
+ }
+ }
+ }
+
+ [Fact]
+ public void TheBackoffIsJitteredRatherThanTheCeiling()
+ {
+ using var client = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ BackoffBase = TimeSpan.FromSeconds(1),
+ BackoffMax = TimeSpan.FromSeconds(8),
+ });
+
+ var draws = Enumerable.Range(0, 50).Select(_ => client.Backoff(3, null)).Distinct().Count();
+
+ Assert.True(draws > 1, $"full jitter draws a random wait, got {draws} distinct value(s)");
+ }
+
+ [Fact]
+ public async Task AnAbsurdRetryAfterDoesNotParkTheCaller()
+ {
+ using var capped = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ BackoffMax = TimeSpan.FromMilliseconds(50),
+ });
+ Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "3600"));
+
+ var failure = await Assert.ThrowsAsync<RateLimitedException>(
+ () => capped.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60)));
+
+ Assert.Equal(TimeSpan.FromSeconds(3600), failure.RetryAfter);
+ Assert.Equal(3, Server.Count("/01001000"));
+ }
+
+ [Theory]
+ [InlineData("120", 120)]
+ [InlineData(" 120 ", 120)]
+ [InlineData("0", 0)]
+ public void RetryAfterInSeconds(string header, int expected) =>
+ Assert.Equal(TimeSpan.FromSeconds(expected), CepisClient.ParseRetryAfter(header));
+
+ [Fact]
+ public void RetryAfterAsAnHttpDate()
+ {
+ var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 2099 07:28:00 GMT");
+
+ Assert.NotNull(parsed);
+ Assert.True(parsed > TimeSpan.Zero);
+ }
+
+ [Fact]
+ public void AnHttpDateInThePastIsZeroRatherThanNegative()
+ {
+ var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 1998 07:28:00 GMT");
+
+ Assert.Equal(TimeSpan.Zero, parsed);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not a date at all")]
+ public void NonsenseRetryAfterIsIgnored(string? header) =>
+ Assert.Null(CepisClient.ParseRetryAfter(header));
+
+ [Fact]
+ public async Task NothingListeningIsANetworkError()
+ {
+ using var unreachable = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = "http://127.0.0.1:1",
+ MaxAttempts = 2,
+ BackoffBase = TimeSpan.FromMilliseconds(1),
+ BackoffMax = TimeSpan.FromMilliseconds(5),
+ Timeout = TimeSpan.FromSeconds(30),
+ });
+
+ await Assert.ThrowsAsync<NetworkException>(() => unreachable.LookupAsync("01001000"));
+ }
+
+ [Fact]
+ public async Task TheTotalTimeoutStopsTheCallShortOfItsAttempts()
+ {
+ const int budgetedAttempts = 200;
+ using var bounded = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ Timeout = TimeSpan.FromSeconds(30),
+ MaxAttempts = budgetedAttempts,
+ BackoffBase = TimeSpan.FromMilliseconds(100),
+ BackoffMax = TimeSpan.FromMilliseconds(100),
+ TotalTimeout = TimeSpan.FromSeconds(3),
+ });
+ Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError));
+
+ var failure = await Assert.ThrowsAsync<ServerErrorException>(
+ () => bounded.LookupAsync("01001000"));
+
+ var attempts = Server.Count("/01001000");
+ Assert.Equal(500, failure.Status);
+ Assert.InRange(attempts, 1, budgetedAttempts - 1);
+ }
+
+ [Fact]
+ public async Task TheTotalTimeoutEndsACallThatNeverGetsAnAnswer()
+ {
+ using var bounded = new CepisClient(new CepisClientOptions
+ {
+ BaseUrl = Server.Url,
+ Timeout = TimeSpan.FromMinutes(1),
+ MaxAttempts = 3,
+ TotalTimeout = TimeSpan.FromMilliseconds(500),
+ });
+ Server.Handle(_ =>
+ {
+ Thread.Sleep(TimeSpan.FromSeconds(10));
+ return Reply.Of(200, Fixtures.SeAddress);
+ });
+
+ await Assert.ThrowsAsync<NetworkException>(
+ () => bounded.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60)));
+
+ Assert.Equal(1, Server.Count("/01001000"));
+ }
+}