# Rust with reqwest and serde, plus curl in shell scripts

> Build an async Rust client with reqwest and serde, and call Danadresse from shell scripts with curl and jq, with retry on 429.
>
> https://danadresse.dk/en/guides/rust-klient · verified 2026-11-02
This guide has two parts. The first is an async Rust client with `reqwest`, `serde` and `tokio` that covers autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, typed errors and retry on 429. The second is a small shell function with `curl` and `jq` for cron jobs, deploy scripts and quick checks, with the same error handling.

Both read the key from the `DANADRESSE_API_KEY` environment variable.

## Rust: dependencies

```toml id=cargo run=no
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
```

`rustls-tls` means you do not need OpenSSL on the build server.

## Rust: the client

The structs only hold the fields the example uses. serde ignores unknown fields, so new fields in the API do not break anything. The `dør` (door) field gets `#[serde(rename = "dør")]`, and `type` is a reserved word, so it is called `r#type`:

```rust id=rust-client run=no
// src/main.rs
use std::time::Duration;

use reqwest::{header, Client, StatusCode};
use serde::de::DeserializeOwned;
use serde::Deserialize;

// ---- Svar-typer (kun de felter, vi bruger; ukendte felter ignoreres) ----

#[derive(Debug, Deserialize)]
pub struct Forslag {
    pub r#type: String,
    pub forslagstekst: String,
    pub data: ForslagData,
}

#[derive(Debug, Deserialize)]
pub struct ForslagData {
    pub id: Option<String>,
    pub postnr: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct Postnummer {
    pub nr: String,
    pub navn: String,
}

#[derive(Debug, Deserialize)]
pub struct Adgangspunkt {
    pub koordinater: Vec<f64>, // [længde, bredde]
}

#[derive(Debug, Deserialize)]
pub struct Adgangsadresse {
    pub id: String,
    pub adressebetegnelse: String,
    pub postnummer: Postnummer,
    pub adgangspunkt: Adgangspunkt,
}

#[derive(Debug, Deserialize)]
pub struct Adresse {
    pub id: String,
    pub adressebetegnelse: String,
    pub etage: Option<String>,
    #[serde(rename = "dør")]
    pub doer: Option<String>,
    pub adgangsadresse: Adgangsadresse,
}

// Datavask-adressen har ingen koordinater: slå /adresser/{id} op, hvis du skal bruge dem
#[derive(Debug, Deserialize)]
pub struct VaskAdresse {
    pub id: String,
    pub vejnavn: String,
    pub husnr: String,
    pub etage: Option<String>,
    #[serde(rename = "dør")]
    pub doer: Option<String>,
    pub postnr: String,
    pub postnrnavn: String,
}

#[derive(Debug, Deserialize)]
pub struct Vaskeresultat {
    pub afstand: u32, // 0 = præcis match
}

#[derive(Debug, Deserialize)]
pub struct VaskResultat {
    pub adresse: VaskAdresse,
    pub aktueladresse: Option<VaskAdresse>, // den gældende version
    pub vaskeresultat: Vaskeresultat,
}

impl VaskResultat {
    /// Den gældende version (afviger, hvis den fundne er nedlagt).
    pub fn bedste(&self) -> &VaskAdresse {
        self.aktueladresse.as_ref().unwrap_or(&self.adresse)
    }
}

#[derive(Debug, Deserialize)]
pub struct VaskSvar {
    pub kategori: String, // "A", "B" eller "C", kun på topniveau (ingen score)
    pub resultater: Vec<VaskResultat>,
}

impl VaskSvar {
    /// A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge.
    pub fn sikker(&self) -> bool {
        (self.kategori == "A" || self.kategori == "B") && !self.resultater.is_empty()
    }
}

// ---- Fejl ----

#[derive(Debug, Deserialize, Default)]
struct FejlKrop {
    code: Option<String>,
    title: Option<String>,
    message: Option<String>,
    details: Option<serde_json::Value>,
    error: Option<Box<FejlKrop>>,
}

#[derive(Debug)]
pub enum DanadresseError {
    // details: fx resets_at og upgrade_url ved quota_exceeded
    Api { status: u16, code: Option<String>, message: String, details: Option<serde_json::Value> },
    Http(reqwest::Error),
}

impl std::fmt::Display for DanadresseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Api { status, code, message, .. } => write!(f, "HTTP {status} {code:?}: {message}"),
            Self::Http(e) => write!(f, "{e}"),
        }
    }
}

impl std::error::Error for DanadresseError {}

impl From<reqwest::Error> for DanadresseError {
    fn from(e: reqwest::Error) -> Self {
        Self::Http(e)
    }
}

// ---- Klienten ----

pub struct Danadresse {
    http: Client,
    base_url: String,
    max_retries: u32,
}

impl Danadresse {
    pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
        let key = std::env::var("DANADRESSE_API_KEY")?;
        let base_url = std::env::var("DANADRESSE_BASE_URL")
            .unwrap_or_else(|_| "https://api.danadresse.dk".to_string());
        let mut headers = header::HeaderMap::new();
        let mut value = header::HeaderValue::from_str(&key)?;
        value.set_sensitive(true); // nøglen skrives ikke i debug-output
        headers.insert(header::HeaderName::from_static("x-api-key"), value);
        let http = Client::builder()
            .default_headers(headers)
            .connect_timeout(Duration::from_secs(3))
            .timeout(Duration::from_secs(10)) // loft pr. forsøg
            .build()?;
        Ok(Self { http, base_url, max_retries: 2 })
    }

    async fn get<T: DeserializeOwned>(&self, path: &str, query: &[(&str, String)]) -> Result<T, DanadresseError> {
        let url = format!("{}{}", self.base_url, path);
        let mut attempt = 0;
        loop {
            let res = match self.http.get(&url).query(query).send().await {
                Ok(res) => res,
                Err(e) if (e.is_timeout() || e.is_connect()) && attempt < self.max_retries => {
                    attempt += 1;
                    tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
                    continue;
                }
                Err(e) => return Err(e.into()),
            };
            let status = res.status();
            if status.is_success() {
                return Ok(res.json::<T>().await?);
            }
            let retry_after = res
                .headers()
                .get(header::RETRY_AFTER)
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse::<u64>().ok());
            // 429 og 503 med Retry-After kan prøves igen; quota_exceeded har ingen Retry-After
            let retryable = status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE;
            if let (true, Some(secs)) = (retryable, retry_after) {
                if attempt < self.max_retries {
                    attempt += 1;
                    tokio::time::sleep(Duration::from_secs(secs.min(30))).await;
                    continue;
                }
            }
            // DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
            let mut krop: FejlKrop = res.json().await.unwrap_or_default();
            if let Some(inner) = krop.error.take() {
                krop = *inner;
            }
            return Err(DanadresseError::Api {
                status: status.as_u16(),
                code: krop.code,
                message: krop.title.or(krop.message).unwrap_or_default(),
                details: krop.details,
            });
        }
    }

    pub async fn autocomplete(&self, q: &str, per_side: u32) -> Result<Vec<Forslag>, DanadresseError> {
        self.get("/autocomplete", &[("q", q.to_string()), ("per_side", per_side.to_string())]).await
    }

    pub async fn adresse(&self, id: &str) -> Result<Adresse, DanadresseError> {
        self.get(&format!("/adresser/{id}"), &[]).await
    }

    pub async fn vask(&self, betegnelse: &str) -> Result<VaskSvar, DanadresseError> {
        self.get("/datavask/adresser", &[("betegnelse", betegnelse.to_string())]).await
    }

    pub async fn reverse(&self, lon: f64, lat: f64) -> Result<Adgangsadresse, DanadresseError> {
        self.get("/adgangsadresser/reverse", &[("x", lon.to_string()), ("y", lat.to_string())]).await
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api = Danadresse::from_env()?;

    for f in api.autocomplete("Rådhuspladsen 1, 4200", 3).await? {
        println!("forslag: {} ({})", f.forslagstekst, f.r#type);
    }

    let a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018").await?;
    let k = &a.adgangsadresse.adgangspunkt.koordinater;
    println!("opslag: {} {} {}", a.adressebetegnelse, k[0], k[1]);

    for tekst in ["Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"] {
        let v = api.vask(tekst).await?;
        println!("datavask: {} {} sikker={}", v.kategori, v.resultater.len(), v.sikker());
    }

    let r = api.reverse(12.5683, 55.6761).await?;
    println!("reverse: {}", r.adressebetegnelse);

    match api.adresse("00000000-0000-0000-0000-000000000000").await {
        Err(DanadresseError::Api { status, code, .. }) => println!("fejl: {status} {code:?}"),
        other => println!("uventet: {other:?}"),
    }
    Ok(())
}
```

Output:

```text id=rust-output run=no
forslag: Rådhuspladsen 1, 4200 Slagelse (adresse)
forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse (adresse)
forslag: Rådhuspladsen 1, st. th, 4200 Slagelse (adresse)
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 Some("not_found")
```

How the main parts work:

- **One `Client`.** `reqwest::Client` has a connection pool and is cheap to clone. Create it once and share it, for example in your Axum or Actix state.
- **Timeouts.** `connect_timeout` caps establishing the connection, and `timeout` caps the whole attempt including the response. If you want a cap for the whole operation including retries, wrap the call in `tokio::time::timeout`.
- **Errors.** The DAWA paths answer `{"type","title","details","code"}`, and `/api/v1/*` answers `{"error":{"code","message","details"}}`, for every error including 500. `FejlKrop` reads both, so `DanadresseError::Api` always carries `code` and `details`.
- **Retry.** 429 and 503 with `Retry-After` (1 to 60 seconds on the per-minute limit) are retried after the given delay, at most 30 seconds and at most twice. `quota_exceeded` has no `Retry-After` and is returned immediately. It means a free account has used its monthly quota and the grace buffer, or a paid account has reached its budget cap. `details["resets_at"]` says when calls are served again. Refused calls are not counted or billed. See [Limits, quotas and 429](/en/guides/graenser-og-429).
- **The key.** `set_sensitive(true)` keeps the header out of the output if you log the request with `{:?}`.

## Shell: curl and jq

For scripts, a function that wraps `curl` is enough. It sends the key, sets timeouts, waits for `Retry-After` on 429 and 503, and writes the error code to stderr:

```bash id=curl-recipes run=live expect="datavask: A"
#!/usr/bin/env bash
# danadresse.sh: kald Danadresse fra shell-scripts (kræver curl og jq)
set -euo pipefail

API="${DANADRESSE_BASE_URL:-https://api.danadresse.dk}"
: "${DANADRESSE_API_KEY:?Sæt DANADRESSE_API_KEY}"

# dawa_get <sti> [curl-argumenter …]
# Skriver svaret på stdout. Venter Retry-After ved 429/503 (højst 2 nye forsøg).
# Fejl: skriver "HTTP <status> <code>" på stderr og returnerer 1.
dawa_get() {
  local path="$1"; shift
  local hdr body status retry attempt
  hdr="$(mktemp)"
  for attempt in 0 1 2; do
    body="$(curl -sS -G "$API$path" "$@" \
      -H "X-Api-Key: $DANADRESSE_API_KEY" \
      --connect-timeout 3 --max-time 10 \
      -D "$hdr" -w '\n%{http_code}')" || { sleep $((2 ** attempt)); continue; }
    status="${body##*$'\n'}"
    body="${body%$'\n'*}"
    if [[ "$status" == 2* ]]; then
      rm -f "$hdr"
      printf '%s\n' "$body"
      return 0
    fi
    retry="$(grep -i '^retry-after:' "$hdr" | tr -dc '0-9' || true)"
    if [[ ("$status" == 429 || "$status" == 503) && -n "$retry" && $attempt -lt 2 ]]; then
      sleep "$retry"
      continue
    fi
    rm -f "$hdr"
    # DAWA-stier har code i roden, /api/v1 under error
    echo "HTTP $status $(jq -r '.code // .error.code // "unknown"' <<<"$body" 2>/dev/null)" >&2
    return 1
  done
  rm -f "$hdr"
  echo "giver op efter 3 forsøg: $path" >&2
  return 1
}

# Autocomplete: én linje pr. forslag
dawa_get /autocomplete --data-urlencode "q=Rådhuspladsen 1, 4200" -d per_side=3 \
  | jq -r '.[] | "forslag: \(.forslagstekst)"'

# Opslag på id
dawa_get /adresser/0a3f50ae-b4ee-32b8-e044-0003ba298018 \
  | jq -r '"opslag: \(.adressebetegnelse) \(.adgangsadresse.adgangspunkt.koordinater | @csv)"'

# Datavask: kategori (kun på topniveau) og antal kandidater
for tekst in "Strandvejen 100, 2900 Hellerup" "Rentemestervej 8, 2. tv, 2400 København NV"; do
  dawa_get /datavask/adresser --data-urlencode "betegnelse=$tekst" \
    | jq -r '"datavask: \(.kategori) \(.resultater | length)"'
done

# Reverse geokodning (x = længdegrad, y = breddegrad)
dawa_get /adgangsadresser/reverse -d x=12.5683 -d y=55.6761 \
  | jq -r '"reverse: \(.adressebetegnelse)"'

# En fejl: ukendt id giver 404 not_found på stderr
dawa_get /adresser/00000000-0000-0000-0000-000000000000 || echo "fejl håndteret"
```

Output:

```text id=curl-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
datavask: C 7
reverse: Rådhuspladsen 3, 1550 København V
HTTP 404 not_found
fejl håndteret
```

A few tips for scripts:

- `-G` together with `--data-urlencode` builds the query string and URL-encodes æ, ø, å and spaces for you.
- `--max-time 10` stops a call that hangs. Without it a cron job can hang until the next run.
- If you look up many addresses from a file, run them in sequence or with low parallelism (`xargs -P 4`). Run hundreds at once and you hit the per-minute limit and get 429.
- `jq -r '.[] | .forslagstekst'` gives one line per suggestion, which is easy to pipe on.

Cleaning or geocoding a whole CSV file? See [Geocode a CSV file](/en/guides/geokod-csv).

## Datavask

Both examples show that "Strandvejen 100, 2900 Hellerup" gives A with one candidate, and "Rentemestervej 8, 2. tv, 2400 København NV" gives C with seven, because that number has no 2. tv. The category exists only at the top level, and there is no score field. A is an exact match. B is exactly one safe match with small spelling differences: store it too, with the official text. C is uncertain: show the candidates and let a person pick. `vaskeresultat.afstand` is 0 for an exact match. `betegnelse` reads floor and door itself. If your file already has separate columns, you can send `vejnavn`, `husnr`, `etage`, `dør` and `postnr` instead.

## 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 Go: [Go client with net/http](/en/guides/go-klient)
