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.
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")]:
// 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.Webmatcher feltnavne uden hensyn til store og små bogstaver, såforslagstekstlander iForslagstekst.ToExceptionAsynclæ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 dige.Code, som er stabil, oge.Details. Forgren på koden, ikke på teksten.ReverseAsyncbrugerFormattableString.Invariant, så koordinaterne skrives med punktum. På en dansk server ville12,5683ellers 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):
// 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.
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:
// 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:
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.
Sikkerer 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.
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:
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
- Grænser, kvoter og 429
- Samme klient i Java: Java og Spring
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)