# C# og .NET: HttpClient med typede records og IHttpClientFactory

> Byg en typed client til Danadresse i .NET 8 med records, IHttpClientFactory, timeouts, fejlkoder og retry på 429 uden Polly.
>
> https://danadresse.dk/guides/csharp-dotnet · verified 2026-11-02
Denne guide viser en typed client til Danadresse i .NET 8. Svarene deserialiseres til records, klienten registreres med `IHttpClientFactory`, og en lille `DelegatingHandler` venter `Retry-After` ved 429, så du ikke behøver Polly. Klienten dækker autocomplete, opslag på id, datavask og reverse geokodning.

Nøglen læses fra miljøvariablen `DANADRESSE_API_KEY` på serveren. Send den aldrig til browseren.

## 1. Records og klient

Læg filen i dit projekt. Records indeholder kun de felter, eksemplet bruger. `System.Text.Json` ignorerer resten, så klienten går ikke i stykker, hvis API'et får nye felter. Feltet `dør` har et dansk bogstav, så det får `[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);
        }
    }
}
```

Et par detaljer:

- `JsonSerializerDefaults.Web` matcher feltnavne uden hensyn til store og små bogstaver, så `forslagstekst` lander i `Forslagstekst`.
- `ToExceptionAsync` læser begge fejlformater. DAWA-stierne (`/adresser`, `/autocomplete` …) svarer `{"type","title","details","code"}`, mens `/api/v1/*` svarer `{"error":{"code","message","details"}}`, for alle fejl, også 500. Begge giver dig `e.Code`, som er stabil, og `e.Details`. Forgren på koden, ikke på teksten.
- `ReverseAsync` bruger `FormattableString.Invariant`, så koordinaterne skrives med punktum. På en dansk server ville `12,5683` ellers blive sendt.

## 2. Registrér klienten med IHttpClientFactory

`AddHttpClient<DanadresseClient>` giver dig en typed client med en håndteret forbindelsespulje. Du undgår både socket-udtømning (en ny `HttpClient` pr. kald) og forældede DNS-opslag (én statisk `HttpClient` for evigt):

```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` skal slutte med `/`, og stierne i klienten skal være relative uden `/` foran. Ellers smider `HttpClient` den sidste del af base-URL'en væk.

## 3. Retry uden Polly

`RetryAfterHandler` sidder i pipelinen foran den egentlige HTTP-handler:

| Svar | Hvad handleren gør |
|---|---|
| 429 `rate_limit_per_minute` med `Retry-After` op til 30 sekunder | Venter så længe og prøver igen, højst to gange |
| 429 `rate_limit_per_minute` med længere `Retry-After` (højst 60) | Giver svaret videre med det samme |
| 503 `upstream_unavailable` med `Retry-After: 60` | Længere end `MaxDelay`, så svaret gives videre. I et baggrundsjob kan du hæve `MaxDelay` |
| 429 `quota_exceeded` | Ingen `Retry-After`. Giver svaret videre, og klienten kaster `DanadresseException` |

`quota_exceeded` betyder, at en gratiskonto har brugt sin månedskvote og bufferen, eller at en betalt konto har nået det budgetloft, den har sat. Det hjælper ikke at vente nogle sekunder. `e.Details` har `resets_at` (hvornår der svares igen), `upgrade_url` og `reason`. Log koden og alarmér. Afviste kald tælles og faktureres ikke. Se [Grænser, kvoter og 429](/guides/graenser-og-429).

`HttpClient.Timeout` på 60 sekunder er det samlede loft for kaldet inklusive ventetid i handleren. Vil du have et loft pr. forsøg, så brug en `CancellationTokenSource` med `CancelAfter` i handleren, eller `Microsoft.Extensions.Http.Resilience`, hvis du alligevel bruger den.

## 4. Uden ASP.NET

I en konsol-app eller en baggrundstjeneste uden DI kan du bygge pipelinen selv. Brug én `HttpClient` i hele programmets levetid og lad `SocketsHttpHandler` forny forbindelserne:

```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 og C

`VaskAsync` returnerer kategorien fra topniveau og kandidaterne, bedste først. Kategorien findes kun på topniveau, og der er intet score-felt. Hvert resultat har `Adresse`, `Aktueladresse` (den gældende version) og `Vaskeresultat.Afstand` (0 = præcis):

- **A**: præcis match. Gem `Resultater[0].Bedste.Id`. "Strandvejen 100, 2900 Hellerup" er sådan et tilfælde.
- **B**: præcis én sikker match med små stavefejl. Gem også den, med den officielle tekst i stedet for det, brugeren skrev. `Sikker` er sand for A og B.
- **C**: usikker, flere kandidater eller større forskelle. Vis kandidaterne, og lad brugeren vælge. "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv kandidater, fordi det nummer ikke har en 2. tv.

`betegnelse` læser selv etage og dør (`2. tv`, `st. th`). Datavask-adressen har ingen koordinater, så slå `/adresser/{id}` op, hvis du skal bruge dem.

Gem adressens id på kunden eller ordren, så du altid kan slå den aktuelle adresse op igen. Se [Gem adresse-id'et](/guides/gem-adresse-id).

## Prøv det med curl først

Det er lettere at fejlsøge, når du ved, hvordan det rå svar ser ud. Eksemplet bruger `jq` til at vise tre af felterne:

```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}"
```

## Næste skridt

- [Fejlformatet: DAWA-stier og /api/v1](/guides/fejlformat)
- [Grænser, kvoter og 429](/guides/graenser-og-429)
- Samme klient i Java: [Java og Spring](/guides/java-spring)
