# 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.
>
> https://danadresse.dk/en/guides/java-spring · verified 2026-11-02
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:

```xml id=maven run=no
<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")`:

```java id=model run=no
// 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`:

```java id=exception run=no
// 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

```java id=client run=no
// 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:

```java id=demo run=no
// 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
        }
    }
}
```

```text id=demo-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 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`:

```java id=spring-config run=no
// 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:

```java id=spring-service run=no
// 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](/en/guides/gem-adresse-id).

## Check the response with curl

Run the same lookup from the terminal before you debug your Java code:

```bash id=curl-check run=live expect="kategori"
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](/en/guides/fejlformat)
- [Limits, quotas and 429](/en/guides/graenser-og-429)
- The same client in C#: [C# and .NET](/en/guides/csharp-dotnet)
