Java and Spring: java.net.http and RestClient
Call Danadresse from Java 17 with java.net.http and Jackson, or from Spring Boot with RestClient, with timeouts, error codes and retry on 429.
This guide shows two Danadresse clients in Java. The first uses java.net.http.HttpClient from JDK 17 and Jackson and works in any Java project. The second uses RestClient from Spring Boot 3.2 and later. Both share the same records and the same exception, and cover autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, timeouts, error codes and 429.
The key is read from the DANADRESSE_API_KEY environment variable on the server.
Dependencies
java.net.http is part of the JDK. For JSON the examples use Jackson, which Spring Boot already includes. Without Spring you add it yourself:
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency>
Records need Jackson 2.12 or later.
1. Records
The records only hold the fields the example uses. The dør (door) field is called doer in Java and mapped with @JsonProperty("dør"):
// DanadresseModel.java: svar-typer (kun de felter, vi bruger; ukendte felter ignoreres) package dk.example.danadresse; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; public final class DanadresseModel { private DanadresseModel() { } public record ForslagData(String id, String postnr, String adgangsadresseid, Double x, Double y) { } public record Forslag(String type, String tekst, String forslagstekst, ForslagData data) { } public record KodeNavn(String kode, String navn) { } public record Postnummer(String nr, String navn) { } public record Adgangspunkt(List<Double> koordinater) { } // [længde, bredde] public record Adgangsadresse(String id, String adressebetegnelse, Postnummer postnummer, KodeNavn kommune, Adgangspunkt adgangspunkt) { } public record Adresse(String id, String adressebetegnelse, String etage, @JsonProperty("dør") String doer, Adgangsadresse adgangsadresse) { } // Datavask-adressen har ingen koordinater: slå /adresser/{id} op, hvis du skal bruge dem public record VaskAdresse(String id, String vejnavn, String husnr, String etage, @JsonProperty("dør") String doer, String postnr, String postnrnavn, String adgangsadresseid) { } public record Vaskeresultat(Integer afstand) { } // 0 = præcis match public record VaskResultat(VaskAdresse adresse, VaskAdresse aktueladresse, Vaskeresultat vaskeresultat) { /** aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt). */ public VaskAdresse bedste() { return aktueladresse != null ? aktueladresse : adresse; } } /** kategori findes kun på topniveau; der er intet score-felt. */ public record VaskSvar(String kategori, List<VaskResultat> resultater) { /** A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge. */ public boolean sikker() { return ("A".equals(kategori) || "B".equals(kategori)) && resultater != null && !resultater.isEmpty(); } } }
2. The exception
Errors on the DAWA paths have the shape {"type","title","details","code"}, while /api/v1/* answers {"error":{"code","message","details"}}. That holds for every error, 500 included. from() reads both and keeps details and Retry-After:
// DanadresseException.java package dk.example.danadresse; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.MissingNode; public final class DanadresseException extends RuntimeException { private static final ObjectMapper JSON = new ObjectMapper(); private final int status; private final String code; // stabil kode, fx "invalid_api_key" private final long retryAfterSeconds; // 0 hvis headeren mangler private final JsonNode details; // fx resets_at og upgrade_url ved quota_exceeded public DanadresseException(int status, String code, String message, long retryAfterSeconds, JsonNode details) { super("HTTP " + status + " " + code + ": " + message); this.status = status; this.code = code; this.retryAfterSeconds = retryAfterSeconds; this.details = details; } /** Læser begge fejlformater: DAWA-stier {type,title,details,code} og /api/v1 {error:{code,message}}. */ public static DanadresseException from(int status, String body, String retryAfterHeader) { String code = null; String message = "HTTP " + status; JsonNode details = MissingNode.getInstance(); try { JsonNode root = JSON.readTree(body); JsonNode err = root.path("error").isObject() ? root.path("error") : root; code = err.path("code").asText(null); message = err.path("title").asText(err.path("message").asText(message)); details = err.path("details"); } catch (Exception ignored) { // ikke JSON, fx en fejlside fra en proxy } long retryAfter = 0; if (retryAfterHeader != null && retryAfterHeader.matches("\\d+")) { retryAfter = Long.parseLong(retryAfterHeader); } return new DanadresseException(status, code, message, retryAfter, details); } public int status() { return status; } public String code() { return code; } public long retryAfterSeconds() { return retryAfterSeconds; } public JsonNode details() { return details; } // fx details().path("resets_at").asText() /** 429 (minutgrænse) og 503 (register nede) med Retry-After. quota_exceeded har ingen. */ public boolean retryable() { return (status == 429 || status == 503) && retryAfterSeconds > 0; } }
Branch on code(), not on the text. retryable() is true for 429 and 503 with Retry-After (1 to 60 seconds on the per-minute limit). quota_exceeded has no Retry-After and is never retryable: a free account has used its monthly quota and the grace buffer, or a paid account has reached its budget cap. details().path("resets_at") says when calls are served again, and details().path("upgrade_url") where to upgrade. Refused calls are not counted or billed.
3. Client with java.net.http
// DanadresseClient.java: java.net.http (Java 17+) + Jackson package dk.example.danadresse; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import dk.example.danadresse.DanadresseModel.Adgangsadresse; import dk.example.danadresse.DanadresseModel.Adresse; import dk.example.danadresse.DanadresseModel.Forslag; import dk.example.danadresse.DanadresseModel.VaskSvar; import java.io.IOException; import java.net.ConnectException; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; public final class DanadresseClient { private static final ObjectMapper JSON = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private static final int MAX_RETRIES = 2; private final HttpClient http; private final URI baseUrl; private final String apiKey; public DanadresseClient(String baseUrl, String apiKey) { this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); this.baseUrl = URI.create(baseUrl); this.apiKey = apiKey; } public static DanadresseClient fromEnv() { String key = System.getenv("DANADRESSE_API_KEY"); if (key == null || key.isBlank()) { throw new IllegalStateException("DANADRESSE_API_KEY er ikke sat"); } String base = System.getenv().getOrDefault("DANADRESSE_BASE_URL", "https://api.danadresse.dk"); return new DanadresseClient(base, key); } public List<Forslag> autocomplete(String q, int perSide) throws IOException, InterruptedException { return get("/autocomplete?q=" + enc(q) + "&per_side=" + perSide, new TypeReference<List<Forslag>>() { }); } public Adresse adresse(String id) throws IOException, InterruptedException { return get("/adresser/" + enc(id), new TypeReference<Adresse>() { }); } public VaskSvar vask(String betegnelse) throws IOException, InterruptedException { return get("/datavask/adresser?betegnelse=" + enc(betegnelse), new TypeReference<VaskSvar>() { }); } public Adgangsadresse reverse(double lon, double lat) throws IOException, InterruptedException { return get("/adgangsadresser/reverse?x=" + lon + "&y=" + lat, new TypeReference<Adgangsadresse>() { }); } private <T> T get(String pathAndQuery, TypeReference<T> type) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder(baseUrl.resolve(pathAndQuery)) .header("X-Api-Key", apiKey) .header("Accept", "application/json") .timeout(Duration.ofSeconds(10)) .GET() .build(); for (int attempt = 0; ; attempt++) { HttpResponse<String> response; try { response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); } catch (HttpTimeoutException | ConnectException e) { if (attempt >= MAX_RETRIES) { throw e; } Thread.sleep(1000L << attempt); continue; } if (response.statusCode() / 100 == 2) { return JSON.readValue(response.body(), type); } DanadresseException err = DanadresseException.from(response.statusCode(), response.body(), response.headers().firstValue("Retry-After").orElse(null)); if (err.retryable() && attempt < MAX_RETRIES) { Thread.sleep(Math.min(err.retryAfterSeconds(), 30) * 1000L); continue; } throw err; } } private static String enc(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8); } }
connectTimeout on the client and timeout on each request cap the waiting time. HttpClient is thread-safe and keeps connections open, so create one instance and share it.
A small demo:
// Demo.java package dk.example.danadresse; import dk.example.danadresse.DanadresseModel.Adresse; import dk.example.danadresse.DanadresseModel.Forslag; import dk.example.danadresse.DanadresseModel.VaskSvar; import java.util.List; public final class Demo { public static void main(String[] args) throws Exception { DanadresseClient api = DanadresseClient.fromEnv(); for (Forslag f : api.autocomplete("Rådhuspladsen 1, 4200", 3)) { System.out.println("forslag: " + f.forslagstekst()); } Adresse a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018"); List<Double> k = a.adgangsadresse().adgangspunkt().koordinater(); System.out.println("opslag: " + a.adressebetegnelse() + " " + k.get(0) + " " + k.get(1)); for (String tekst : List.of("Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV")) { VaskSvar v = api.vask(tekst); System.out.println("datavask: " + v.kategori() + " " + v.resultater().size() + " sikker=" + v.sikker()); } System.out.println("reverse: " + api.reverse(12.5683, 55.6761).adressebetegnelse()); try { api.adresse("00000000-0000-0000-0000-000000000000"); } catch (DanadresseException e) { System.out.println("fejl: " + e.status() + " " + e.code()); // fejl: 404 not_found } } }
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 11.35623868 55.40464424 datavask: A 1 sikker=true datavask: C 7 sikker=false reverse: Rådhuspladsen 3, 1550 København V fejl: 404 not_found
4. Spring Boot with RestClient
In Spring Boot 3.2 and later you build a RestClient bean with the base URL, key, timeouts and a status handler that turns every 4xx and 5xx response into a DanadresseException:
// DanadresseConfig.java (Spring Boot 3.2+) package dk.example.danadresse; import java.nio.charset.StandardCharsets; import java.time.Duration; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; @Configuration public class DanadresseConfig { @Bean RestClient danadresseRestClient( RestClient.Builder builder, @Value("${danadresse.base-url:https://api.danadresse.dk}") String baseUrl, @Value("${DANADRESSE_API_KEY}") String apiKey) { var factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(Duration.ofSeconds(3)); factory.setReadTimeout(Duration.ofSeconds(10)); return builder .baseUrl(baseUrl) .defaultHeader("X-Api-Key", apiKey) .requestFactory(factory) // Alle 4xx/5xx bliver til DanadresseException med API'ets code .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); throw DanadresseException.from(response.getStatusCode().value(), body, response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)); }) .build(); } }
@Value("${DANADRESSE_API_KEY}") reads the environment variable directly. You can also set danadresse.base-url in application.yml, for example to point at a mock server in your tests.
The service uses the same records and wraps each call in a retry layer:
// DanadresseService.java package dk.example.danadresse; import dk.example.danadresse.DanadresseModel.Adgangsadresse; import dk.example.danadresse.DanadresseModel.Adresse; import dk.example.danadresse.DanadresseModel.Forslag; import dk.example.danadresse.DanadresseModel.VaskSvar; import java.util.List; import java.util.function.Supplier; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; @Service public class DanadresseService { private static final int MAX_RETRIES = 2; private final RestClient rest; public DanadresseService(RestClient danadresseRestClient) { this.rest = danadresseRestClient; } public List<Forslag> autocomplete(String q, int perSide) { return withRetry(() -> rest.get() .uri(u -> u.path("/autocomplete").queryParam("q", q).queryParam("per_side", perSide).build()) .retrieve() .body(new ParameterizedTypeReference<List<Forslag>>() { })); } public Adresse adresse(String id) { return withRetry(() -> rest.get().uri("/adresser/{id}", id).retrieve().body(Adresse.class)); } public VaskSvar vask(String betegnelse) { return withRetry(() -> rest.get() .uri(u -> u.path("/datavask/adresser").queryParam("betegnelse", betegnelse).build()) .retrieve() .body(VaskSvar.class)); } public Adgangsadresse reverse(double lon, double lat) { return withRetry(() -> rest.get() .uri(u -> u.path("/adgangsadresser/reverse").queryParam("x", lon).queryParam("y", lat).build()) .retrieve() .body(Adgangsadresse.class)); } private <T> T withRetry(Supplier<T> call) { for (int attempt = 0; ; attempt++) { try { return call.get(); } catch (DanadresseException e) { if (!e.retryable() || attempt >= MAX_RETRIES) { throw e; } try { Thread.sleep(Math.min(e.retryAfterSeconds(), 30) * 1000L); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; } } } } }
Spring Boot configures Jackson to ignore unknown fields, so the records work without extra setup. A connection error or timeout surfaces as Spring's ResourceAccessException and is not retried by withRetry. Add it if your call is idempotent and you want to retry.
Datavask: when can you store the address?
| Response | Do this |
|---|---|
| A | Exact match. Store resultater().get(0).bedste().id() |
| B | Exactly one safe match with small spelling differences. Store it too, with the official text |
| C | Uncertain: several candidates or larger differences. Show the candidates and let the user pick |
sikker() is true for A and B. The category exists only at the top level, and there is no score field. vaskeresultat().afstand() is 0 for an exact match. "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. If your form already has separate fields, you can send vejnavn, husnr, etage, dør and postnr instead. Store the address id, not only the text. See Store the address id.
Check the response with curl
Run the same lookup from the terminal before you debug your Java code:
curl -s -G "https://api.danadresse.dk/datavask/adresser" \ --data-urlencode "betegnelse=Strandvejen 100, 2900 Hellerup" \ -H "X-Api-Key: $DANADRESSE_API_KEY" | head -c 300
Next steps
- The error format: DAWA paths and /api/v1
- Limits, quotas and 429
- The same client in C#: C# and .NET
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)