# A Go client with net/http and context timeouts

> Write a Danadresse client in Go using only the standard library, context timeouts, typed structs, error codes and Retry-After on 429.
>
> https://danadresse.dk/en/guides/go-klient · verified 2026-11-02
Go's standard library has everything a Danadresse client needs: `net/http`, `encoding/json` and `context`. This guide gives you a package of about 200 lines with autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, typed errors and retry on 429. The code needs Go 1.21 or later, because it uses the built-in `min`.

The key is read from the `DANADRESSE_API_KEY` environment variable.

## 1. The package

Put the file in `danadresse/client.go` in your module. The structs only hold the fields the example uses. `encoding/json` ignores the rest, so new fields in the API do not break anything. The `dør` (door) field gets the tag `json:"dør"`, and fields that can be `null` are pointers:

```go id=client run=no
// Package danadresse er en lille klient til Danadresse API (kun standardbiblioteket).
package danadresse

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"time"
)

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

type Forslag struct {
	Type          string `json:"type"`
	Tekst         string `json:"tekst"`
	Forslagstekst string `json:"forslagstekst"`
	Data          struct {
		ID               string  `json:"id"`
		Postnr           string  `json:"postnr"`
		Adgangsadresseid string  `json:"adgangsadresseid"`
		X                float64 `json:"x"`
		Y                float64 `json:"y"`
	} `json:"data"`
}

type Adgangsadresse struct {
	ID                string `json:"id"`
	Adressebetegnelse string `json:"adressebetegnelse"`
	Postnummer        struct {
		Nr   string `json:"nr"`
		Navn string `json:"navn"`
	} `json:"postnummer"`
	Kommune struct {
		Kode string `json:"kode"`
		Navn string `json:"navn"`
	} `json:"kommune"`
	Adgangspunkt struct {
		Koordinater []float64 `json:"koordinater"` // [længde, bredde]
	} `json:"adgangspunkt"`
}

type Adresse struct {
	ID                string         `json:"id"`
	Adressebetegnelse string         `json:"adressebetegnelse"`
	Etage             *string        `json:"etage"`
	Doer              *string        `json:"dør"`
	Adgangsadresse    Adgangsadresse `json:"adgangsadresse"`
}

// VaskAdresse har ingen koordinater: slå /adresser/{id} op, hvis du skal bruge dem.
type VaskAdresse struct {
	ID               string  `json:"id"`
	Vejnavn          string  `json:"vejnavn"`
	Husnr            string  `json:"husnr"`
	Etage            *string `json:"etage"`
	Doer             *string `json:"dør"`
	Postnr           string  `json:"postnr"`
	Postnrnavn       string  `json:"postnrnavn"`
	Adgangsadresseid string  `json:"adgangsadresseid"`
}

type VaskResultat struct {
	Adresse       VaskAdresse  `json:"adresse"`
	Aktueladresse *VaskAdresse `json:"aktueladresse"` // den gældende version
	Vaskeresultat struct {
		Afstand int `json:"afstand"` // 0 = præcis match
	} `json:"vaskeresultat"`
}

// Bedste giver den gældende version (afviger, hvis den fundne er nedlagt).
func (r VaskResultat) Bedste() VaskAdresse {
	if r.Aktueladresse != nil {
		return *r.Aktueladresse
	}
	return r.Adresse
}

type VaskSvar struct {
	Kategori   string         `json:"kategori"` // "A", "B" eller "C", kun på topniveau
	Resultater []VaskResultat `json:"resultater"`
}

// Sikker: A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge.
func (v VaskSvar) Sikker() bool {
	return (v.Kategori == "A" || v.Kategori == "B") && len(v.Resultater) > 0
}

// ---- Fejl ----

type APIError struct {
	Status     int
	Code       string // stabil kode, fx "invalid_api_key" eller "rate_limit_per_minute"
	Message    string
	Details    json.RawMessage // fx resets_at og upgrade_url ved quota_exceeded
	RetryAfter time.Duration   // 0 hvis headeren mangler
}

func (e *APIError) Error() string {
	return fmt.Sprintf("danadresse: HTTP %d %s: %s", e.Status, e.Code, e.Message)
}

// ---- Klienten ----

type Client struct {
	BaseURL    string
	APIKey     string
	HTTP       *http.Client
	MaxRetries int
}

// NewFromEnv læser DANADRESSE_API_KEY og (valgfrit) DANADRESSE_BASE_URL.
func NewFromEnv() (*Client, error) {
	key := os.Getenv("DANADRESSE_API_KEY")
	if key == "" {
		return nil, errors.New("DANADRESSE_API_KEY er ikke sat")
	}
	base := os.Getenv("DANADRESSE_BASE_URL")
	if base == "" {
		base = "https://api.danadresse.dk"
	}
	return &Client{
		BaseURL:    base,
		APIKey:     key,
		HTTP:       &http.Client{Timeout: 10 * time.Second}, // loft pr. forsøg
		MaxRetries: 2,
	}, nil
}

func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error {
	u := c.BaseURL + path
	if len(q) > 0 {
		u += "?" + q.Encode()
	}
	for attempt := 0; ; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
		if err != nil {
			return err
		}
		req.Header.Set("X-Api-Key", c.APIKey)
		req.Header.Set("Accept", "application/json")

		resp, err := c.HTTP.Do(req)
		if err != nil {
			// netværksfejl eller timeout: prøv igen, medmindre ctx er udløbet
			if ctx.Err() != nil || attempt >= c.MaxRetries {
				return err
			}
			if err := sleep(ctx, time.Duration(1<<attempt)*time.Second); err != nil {
				return err
			}
			continue
		}
		body, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			return err
		}
		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
			return json.Unmarshal(body, out)
		}

		apiErr := parseError(resp, body)
		retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable
		// quota_exceeded har ingen Retry-After og gives videre med det samme
		if retryable && apiErr.RetryAfter > 0 && attempt < c.MaxRetries {
			if err := sleep(ctx, min(apiErr.RetryAfter, 30*time.Second)); err != nil {
				return err
			}
			continue
		}
		return apiErr
	}
}

func parseError(resp *http.Response, body []byte) *APIError {
	// DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
	var b struct {
		Code    string          `json:"code"`
		Title   string          `json:"title"`
		Details json.RawMessage `json:"details"`
		Error   *struct {
			Code    string          `json:"code"`
			Message string          `json:"message"`
			Details json.RawMessage `json:"details"`
		} `json:"error"`
	}
	_ = json.Unmarshal(body, &b)
	e := &APIError{Status: resp.StatusCode, Code: b.Code, Message: b.Title, Details: b.Details}
	if b.Error != nil {
		e.Code, e.Message, e.Details = b.Error.Code, b.Error.Message, b.Error.Details
	}
	if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
		e.RetryAfter = time.Duration(s) * time.Second
	}
	return e
}

func sleep(ctx context.Context, d time.Duration) error {
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-t.C:
		return nil
	}
}

func (c *Client) Autocomplete(ctx context.Context, q string, perSide int) ([]Forslag, error) {
	var out []Forslag
	err := c.get(ctx, "/autocomplete", url.Values{"q": {q}, "per_side": {strconv.Itoa(perSide)}}, &out)
	return out, err
}

func (c *Client) Adresse(ctx context.Context, id string) (*Adresse, error) {
	var out Adresse
	if err := c.get(ctx, "/adresser/"+url.PathEscape(id), nil, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func (c *Client) Vask(ctx context.Context, betegnelse string) (*VaskSvar, error) {
	var out VaskSvar
	if err := c.get(ctx, "/datavask/adresser", url.Values{"betegnelse": {betegnelse}}, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func (c *Client) Reverse(ctx context.Context, lon, lat float64) (*Adgangsadresse, error) {
	var out Adgangsadresse
	q := url.Values{
		"x": {strconv.FormatFloat(lon, 'f', -1, 64)},
		"y": {strconv.FormatFloat(lat, 'f', -1, 64)},
	}
	if err := c.get(ctx, "/adgangsadresser/reverse", q, &out); err != nil {
		return nil, err
	}
	return &out, nil
}
```

## 2. Two kinds of timeout

The client uses two layers:

- `http.Client{Timeout: 10 * time.Second}` caps one attempt, including reading the response.
- The `context` you pass in caps the whole operation, including time spent waiting between retries. `sleep()` honours it, so a cancelled request in your HTTP server also stops the wait.

In an HTTP handler, pass `r.Context()` along. Then the call to Danadresse stops if the user closes the connection.

## 3. Errors and 429

`parseError` reads both error formats. The DAWA paths (`/adresser`, `/autocomplete` …) answer `{"type","title","details","code"}`, and `/api/v1/*` answers `{"error":{"code","message","details"}}`, for every error including 500. You always get an `*APIError` with `Code` and `Details`, which you can branch on with `errors.As`:

| Status | `Code` | What the client does |
|---|---|---|
| 429 | `rate_limit_per_minute` | Waits `Retry-After` (1 to 60 s, at most 30 s at a time) and retries, at most twice |
| 503 | `upstream_unavailable` | Waits `Retry-After` (at most 30 s) and retries |
| 429 | `quota_exceeded` | Returns the error immediately. There is no `Retry-After` |
| 401/403 | `invalid_api_key`, `key_blocked` … | Returns the error. Retrying does not help |
| 404 | `not_found` | Returns the error. The id does not exist |
| 500 | `internal_error` | Returns the error. Log it and try again later |

`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. Do not retry it. `Details` holds `resets_at` (when calls are served again), `upgrade_url` and `reason`. Refused calls are not counted or billed. See [Limits, quotas and 429](/en/guides/graenser-og-429).

## 4. Use it

The module is called `example.com/app` in the example (`go mod init example.com/app`). Change the import path to your own module:

```go id=main run=no
// cmd/demo/main.go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	"example.com/app/danadresse"
)

func main() {
	api, err := danadresse.NewFromEnv()
	if err != nil {
		log.Fatal(err)
	}
	// Samlet loft for hele forløbet, inkl. ventetid ved retries
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	forslag, err := api.Autocomplete(ctx, "Rådhuspladsen 1, 4200", 3)
	if err != nil {
		log.Fatal(err)
	}
	for _, f := range forslag {
		fmt.Println("forslag:", f.Forslagstekst)
	}

	a, err := api.Adresse(ctx, "0a3f50ae-b4ee-32b8-e044-0003ba298018")
	if err != nil {
		log.Fatal(err)
	}
	k := a.Adgangsadresse.Adgangspunkt.Koordinater
	fmt.Println("opslag:", a.Adressebetegnelse, k[0], k[1])

	for _, tekst := range []string{"Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"} {
		v, err := api.Vask(ctx, tekst)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println("datavask:", v.Kategori, len(v.Resultater), "sikker:", v.Sikker())
	}

	r, err := api.Reverse(ctx, 12.5683, 55.6761)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("reverse:", r.Adressebetegnelse)

	_, err = api.Adresse(ctx, "00000000-0000-0000-0000-000000000000")
	var apiErr *danadresse.APIError
	if errors.As(err, &apiErr) {
		fmt.Println("fejl:", apiErr.Status, apiErr.Code) // fejl: 404 not_found
	}
}
```

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

`Client` is safe to share between goroutines, because `http.Client` is. Create one at startup and reuse it so connections stay open.

## Datavask: when can you store the address?

`Vask` returns a `VaskSvar` with the top-level `Kategori` and `Resultater`, best first. The category exists only at the top level, and there is no score field:

- A: exact match. Store `Resultater[0].Bedste().ID`. "Strandvejen 100, 2900 Hellerup" gives A with one candidate.
- B: exactly one safe match with small spelling differences. Store it too, with the official text. `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, because that number has no 2. tv.

`Vaskeresultat.Afstand` is 0 for an exact match. `betegnelse` reads floor and door itself. If your form already has separate fields, you can send `vejnavn`, `husnr`, `etage`, `dør` and `postnr` as query parameters instead. See [Store the address id](/en/guides/gem-adresse-id) for why you should store the id.

## Parallel calls

If you need to look up many addresses, limit the number of concurrent calls. Otherwise you hit the per-minute limit:

```go id=parallel run=no
// Højst 5 samtidige opslag
sem := make(chan struct{}, 5)
var wg sync.WaitGroup
for _, id := range ids {
	wg.Add(1)
	go func(id string) {
		defer wg.Done()
		sem <- struct{}{}
		defer func() { <-sem }()
		if _, err := api.Adresse(ctx, id); err != nil {
			log.Printf("%s: %v", id, err)
		}
	}(id)
}
wg.Wait()
```

## Check with curl

The same reverse lookup from the terminal:

```bash id=curl-check run=live expect="Rådhuspladsen"
curl -s "https://api.danadresse.dk/adgangsadresser/reverse?x=12.5683&y=55.6761" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" | jq -r .adressebetegnelse
```

## Next steps

- [The error format: DAWA paths and /api/v1](/en/guides/fejlformat)
- [Coordinates and srid](/en/guides/koordinater-srid)
- The same client in Rust: [Rust client with reqwest](/en/guides/rust-klient)
