# C# and .NET: HttpClient with typed records and IHttpClientFactory

> Build a typed Danadresse client in .NET 8 with records, IHttpClientFactory, timeouts, error codes and retry on 429 without Polly.
>
> https://danadresse.dk/en/guides/csharp-dotnet · verified 2026-11-02
This guide shows a typed Danadresse client in .NET 8. Responses are deserialised into records, the client is registered with `IHttpClientFactory`, and a small `DelegatingHandler` waits for `Retry-After` on 429, so you do not need Polly. The client covers autocomplete, lookup by id, datavask (address cleansing) and reverse geocoding.

The key is read from the `DANADRESSE_API_KEY` environment variable on the server. Never send it to the browser.

## 1. Records and client

Add the file to your project. The records only hold the fields the example uses. `System.Text.Json` ignores the rest, so the client does not break when the API gains new fields. The `dør` (door) field contains a Danish letter, so it gets `[JsonPropertyName("dør")]`:

```csharp id=client run=no
// DanadresseClient.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Danadresse;

// ---- Svar-typer (kun de felter, vi bruger; resten ignoreres) ----

public sealed record ForslagData(
    string? Id,
    string? Vejnavn,
    string? Husnr,
    string? Etage,
    [property: JsonPropertyName("dør")] string? Doer,
    string? Postnr,
    string? Postnrnavn,
    string? Adgangsadresseid,
    double? X,
    double? Y);

public sealed record Forslag(string Type, string Tekst, string Forslagstekst, ForslagData Data);

public sealed record KodeNavn(string Kode, string Navn);
public sealed record Postnummer(string Nr, string Navn);
public sealed record Adgangspunkt(double[] Koordinater);

public sealed record Adgangsadresse(
    string Id,
    string Adressebetegnelse,
    Postnummer Postnummer,
    KodeNavn Kommune,
    Adgangspunkt Adgangspunkt);

public sealed record Adresse(
    string Id,
    string Adressebetegnelse,
    string? Etage,
    [property: JsonPropertyName("dør")] string? Doer,
    Adgangsadresse Adgangsadresse);

// Datavask-adressen har ingen koordinater: slå /adresser/{id} op, hvis du skal bruge dem
public sealed record VaskAdresse(
    string Id,
    string Vejnavn,
    string Husnr,
    string? Etage,
    [property: JsonPropertyName("dør")] string? Doer,
    string Postnr,
    string Postnrnavn,
    string? Adgangsadresseid);

public sealed record Vaskeresultat(int Afstand);  // 0 = præcis match

public sealed record VaskResultat(VaskAdresse Adresse, VaskAdresse? Aktueladresse, Vaskeresultat Vaskeresultat)
{
    // Aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt)
    public VaskAdresse Bedste => Aktueladresse ?? Adresse;
}

public sealed record VaskSvar(string Kategori, List<VaskResultat> Resultater)  // kategori kun på topniveau
{
    // A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge
    public bool Sikker => (Kategori is "A" or "B") && Resultater.Count > 0;
}

// ---- Fejl ----

public sealed class DanadresseException(HttpStatusCode status, string? code, string message, JsonElement? details = null)
    : Exception($"HTTP {(int)status} {code}: {message}")
{
    public HttpStatusCode Status { get; } = status;
    public string? Code { get; } = code;  // stabil kode, fx "invalid_api_key"
    public JsonElement? Details { get; } = details;  // fx resets_at og upgrade_url ved quota_exceeded
}

// ---- Klienten (typed client til IHttpClientFactory) ----

public sealed class DanadresseClient(HttpClient http)
{
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    public Task<List<Forslag>> AutocompleteAsync(string q, int perSide = 5, CancellationToken ct = default) =>
        GetAsync<List<Forslag>>($"autocomplete?q={Uri.EscapeDataString(q)}&per_side={perSide}", ct);

    public Task<Adresse> AdresseAsync(string id, CancellationToken ct = default) =>
        GetAsync<Adresse>($"adresser/{Uri.EscapeDataString(id)}", ct);

    public Task<VaskSvar> VaskAsync(string betegnelse, CancellationToken ct = default) =>
        GetAsync<VaskSvar>($"datavask/adresser?betegnelse={Uri.EscapeDataString(betegnelse)}", ct);

    public Task<Adgangsadresse> ReverseAsync(double lon, double lat, CancellationToken ct = default) =>
        GetAsync<Adgangsadresse>(FormattableString.Invariant($"adgangsadresser/reverse?x={lon}&y={lat}"), ct);

    private async Task<T> GetAsync<T>(string relativeUrl, CancellationToken ct)
    {
        using var response = await http.GetAsync(relativeUrl, ct);
        if (response.IsSuccessStatusCode)
        {
            return (await response.Content.ReadFromJsonAsync<T>(Json, ct))!;
        }
        throw await ToExceptionAsync(response, ct);
    }

    private static async Task<DanadresseException> ToExceptionAsync(HttpResponseMessage response, CancellationToken ct)
    {
        string? code = null;
        JsonElement? details = null;
        var message = response.ReasonPhrase ?? "Fejl";
        try
        {
            using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
            var root = doc.RootElement;
            // DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
            if (root.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.Object)
            {
                root = err;
            }
            if (root.TryGetProperty("code", out var c)) code = c.GetString();
            if (root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Object) details = d.Clone();
            if (root.TryGetProperty("title", out var t)) message = t.GetString() ?? message;
            else if (root.TryGetProperty("message", out var m)) message = m.GetString() ?? message;
        }
        catch (JsonException)
        {
            // ikke JSON, fx en fejlside fra en proxy
        }
        return new DanadresseException(response.StatusCode, code, message, details);
    }
}

// ---- Retry uden Polly: venter Retry-After ved 429/503 ----

public sealed class RetryAfterHandler : DelegatingHandler
{
    private const int MaxRetries = 2;
    // Minutgrænsen svarer Retry-After 1-60 s (til minutvinduet nulstilles).
    // Et længere Retry-After end MaxDelay gives videre til kalderen.
    private static readonly TimeSpan MaxDelay = TimeSpan.FromSeconds(30);

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        for (var attempt = 0; ; attempt++)
        {
            var response = await base.SendAsync(request, ct);
            var retryable = response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable;
            // quota_exceeded har ingen Retry-After, så Delta er null, og fejlen gives videre
            var delay = response.Headers.RetryAfter?.Delta;
            if (!retryable || delay is null || delay > MaxDelay || attempt >= MaxRetries)
            {
                return response;
            }
            response.Dispose();
            await Task.Delay(delay.Value, ct);
        }
    }
}
```

A few details:

- `JsonSerializerDefaults.Web` matches property names case-insensitively, so `forslagstekst` ends up in `Forslagstekst`.
- `ToExceptionAsync` reads both error formats. The DAWA paths (`/adresser`, `/autocomplete` …) answer `{"type","title","details","code"}`, while `/api/v1/*` answers `{"error":{"code","message","details"}}`, for every error including 500. Both give you `e.Code`, which is stable, and `e.Details`. Branch on the code, not on the text.
- `ReverseAsync` uses `FormattableString.Invariant`, so coordinates are written with a decimal point. On a server with Danish culture, `12,5683` would be sent otherwise.

## 2. Register the client with IHttpClientFactory

`AddHttpClient<DanadresseClient>` gives you a typed client with a managed connection pool. You avoid both socket exhaustion (a new `HttpClient` per call) and stale DNS (one static `HttpClient` forever):

```csharp id=program run=no
// Program.cs (ASP.NET Core minimal API)
using Danadresse;

var builder = WebApplication.CreateBuilder(args);

var apiKey = Environment.GetEnvironmentVariable("DANADRESSE_API_KEY")
    ?? throw new InvalidOperationException("DANADRESSE_API_KEY mangler");
var baseUrl = Environment.GetEnvironmentVariable("DANADRESSE_BASE_URL") ?? "https://api.danadresse.dk";

builder.Services.AddTransient<RetryAfterHandler>();
builder.Services
    .AddHttpClient<DanadresseClient>(client =>
    {
        client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");  // skal slutte med /
        client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
        client.Timeout = TimeSpan.FromSeconds(60);  // samlet loft for kald + ventetid ved retries
    })
    .AddHttpMessageHandler<RetryAfterHandler>();

var app = builder.Build();

app.MapGet("/adresse/forslag", async (string q, DanadresseClient api, CancellationToken ct) =>
{
    if (q.Trim().Length < 2) return Results.Ok(Array.Empty<object>());
    try
    {
        var forslag = await api.AutocompleteAsync(q, 8, ct);
        return Results.Ok(forslag.Select(f => new { tekst = f.Forslagstekst, id = f.Data.Id }));
    }
    catch (DanadresseException e)
    {
        app.Logger.LogWarning("Danadresse {Status} {Code}", (int)e.Status, e.Code);
        return Results.StatusCode(StatusCodes.Status502BadGateway);
    }
});

app.MapGet("/adresse/{id}", async (string id, DanadresseClient api, CancellationToken ct) =>
{
    try
    {
        var a = await api.AdresseAsync(id, ct);
        var koord = a.Adgangsadresse.Adgangspunkt.Koordinater;
        return Results.Ok(new { a.Id, a.Adressebetegnelse, lon = koord[0], lat = koord[1] });
    }
    catch (DanadresseException e) when (e.Code == "not_found")
    {
        return Results.NotFound();
    }
});

app.Run();
```

`BaseAddress` must end with `/`, and the paths in the client must be relative without a leading `/`. Otherwise `HttpClient` drops the last segment of the base URL.

## 3. Retry without Polly

`RetryAfterHandler` sits in the pipeline in front of the actual HTTP handler:

| Response | What the handler does |
|---|---|
| 429 `rate_limit_per_minute` with `Retry-After` up to 30 seconds | Waits that long and retries, at most twice |
| 429 `rate_limit_per_minute` with a longer `Retry-After` (at most 60) | Passes the response on immediately |
| 503 `upstream_unavailable` with `Retry-After: 60` | Longer than `MaxDelay`, so it passes the response on. In a background job you can raise `MaxDelay` |
| 429 `quota_exceeded` | No `Retry-After`. Passes the response on, and the client throws `DanadresseException` |

`quota_exceeded` means a free account has used its monthly quota and the grace buffer, or a paid account has reached the budget cap it set. Waiting a few seconds does not help. `e.Details` holds `resets_at` (when calls are served again), `upgrade_url` and `reason`. Log the code and raise an alert. Refused calls are not counted or billed. See [Limits, quotas and 429](/en/guides/graenser-og-429).

`HttpClient.Timeout` of 60 seconds is the overall cap for the call, including time spent waiting in the handler. If you want a cap per attempt, use a `CancellationTokenSource` with `CancelAfter` inside the handler, or `Microsoft.Extensions.Http.Resilience` if you already use it.

## 4. Without ASP.NET

In a console app or background service without DI you can build the pipeline yourself. Use one `HttpClient` for the lifetime of the program and let `SocketsHttpHandler` renew connections:

```csharp id=console run=no
// Konsol-app eller worker uden DI-container
using Danadresse;

var handler = new RetryAfterHandler
{
    // SocketsHttpHandler fornyer forbindelser, så DNS-ændringer opdages
    InnerHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) },
};
using var http = new HttpClient(handler)
{
    BaseAddress = new Uri("https://api.danadresse.dk/"),
    Timeout = TimeSpan.FromSeconds(60),
};
http.DefaultRequestHeaders.Add("X-Api-Key", Environment.GetEnvironmentVariable("DANADRESSE_API_KEY"));

var api = new DanadresseClient(http);

foreach (var f in await api.AutocompleteAsync("Rådhuspladsen 1, 4200", 3))
    Console.WriteLine($"forslag: {f.Forslagstekst}");

var a = await api.AdresseAsync("0a3f50ae-b4ee-32b8-e044-0003ba298018");
Console.WriteLine($"opslag: {a.Adressebetegnelse}");

foreach (var tekst in new[] { "Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV" })
{
    var v = await api.VaskAsync(tekst);
    var svar = v.Sikker ? v.Resultater[0].Bedste.Id : "lad brugeren vælge";
    Console.WriteLine($"datavask: {v.Kategori} {v.Resultater.Count} {svar}");
}

var r = await api.ReverseAsync(12.5683, 55.6761);
Console.WriteLine($"reverse: {r.Adressebetegnelse}");

try
{
    await api.AdresseAsync("00000000-0000-0000-0000-000000000000");
}
catch (DanadresseException e)
{
    Console.WriteLine($"fejl: {(int)e.Status} {e.Code}");
}
```

Output:

```text id=console-output run=no
forslag: Rådhuspladsen 1, 4200 Slagelse
forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse
forslag: Rådhuspladsen 1, st. th, 4200 Slagelse
opslag: Rådhuspladsen 1, st. th, 4200 Slagelse
datavask: A 1 fec4202d-1015-425d-a3f5-5d21d03194b6
datavask: C 7 lad brugeren vælge
reverse: Rådhuspladsen 3, 1550 København V
fejl: 404 not_found
```

## Datavask: A, B and C

`VaskAsync` returns the top-level category and the candidates, best first. The category exists only at the top level, and there is no score field. Each result has `Adresse`, `Aktueladresse` (the current version) and `Vaskeresultat.Afstand` (0 = exact):

- **A**: exact match. Store `Resultater[0].Bedste.Id`. "Strandvejen 100, 2900 Hellerup" is such a case.
- **B**: exactly one safe match with small spelling differences. Store it too, with the official text rather than what the user typed. `Sikker` is true for A and B.
- **C**: uncertain, several candidates or larger differences. Show the candidates and let the user pick. "Rentemestervej 8, 2. tv, 2400 København NV" gives C with seven candidates, because that number has no 2. tv.

`betegnelse` reads floor and door itself (`2. tv`, `st. th`). The datavask address has no coordinates, so look up `/adresser/{id}` if you need them.

Store the address id on the customer or order, so you can always look up the current address again. See [Store the address id](/en/guides/gem-adresse-id).

## Try it with curl first

Debugging is easier when you know what the raw response looks like. The example uses `jq` to show three of the fields:

```bash id=curl-check run=live expect="Slagelse"
curl -s "https://api.danadresse.dk/adresser/0a3f50ae-b4ee-32b8-e044-0003ba298018" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" | jq "{id, kvhx, adressebetegnelse}"
```

## Next steps

- [The error format: DAWA paths and /api/v1](/en/guides/fejlformat)
- [Limits, quotas and 429](/en/guides/graenser-og-429)
- The same client in Java: [Java and Spring](/en/guides/java-spring)
