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.

5 min Level: Intermediate ✓ Code tested

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")]:

C##client
// 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):

C##program
// 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:

ResponseWhat the handler does
429 rate_limit_per_minute with Retry-After up to 30 secondsWaits 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: 60Longer than MaxDelay, so it passes the response on. In a background job you can raise MaxDelay
429 quota_exceededNo 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.

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:

C##console
// 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#console-output
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.

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:

Shell#curl-check
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

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →