# En Go-klient med net/http og context-timeouts

> Skriv en Go-klient til Danadresse med kun standardbiblioteket, context-timeouts, typede structs, fejlkoder og Retry-After ved 429.
>
> https://danadresse.dk/guides/go-klient · verified 2026-11-02
Go's standardbibliotek har alt, hvad en klient til Danadresse kræver: `net/http`, `encoding/json` og `context`. Denne guide giver dig en pakke på omkring 200 linjer med autocomplete, opslag på id, datavask, reverse geokodning, typede fejl og retry på 429. Koden kræver Go 1.21 eller nyere, fordi den bruger den indbyggede `min`.

Nøglen læses fra miljøvariablen `DANADRESSE_API_KEY`.

## 1. Pakken

Læg filen i `danadresse/client.go` i dit modul. Structs indeholder kun de felter, eksemplet bruger. `encoding/json` ignorerer resten, så nye felter i API'et ikke ødelægger noget. Feltet `dør` får tagget `json:"dør"`, og felter, der kan være `null`, er pointere:

```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. To slags timeouts

Klienten bruger to lag:

- `http.Client{Timeout: 10 * time.Second}` er loftet for ét forsøg, inklusive læsning af svaret.
- Den `context`, du sender med, er loftet for hele forløbet, inklusive ventetid ved retries. `sleep()` respekterer den, så en annulleret request i din HTTP-server stopper også ventetiden.

I en HTTP-handler sender du `r.Context()` videre. Så stopper kaldet til Danadresse, hvis brugeren lukker forbindelsen.

## 3. Fejl og 429

`parseError` læser begge fejlformater. DAWA-stierne (`/adresser`, `/autocomplete` …) svarer `{"type","title","details","code"}`, og `/api/v1/*` svarer `{"error":{"code","message","details"}}`, for alle fejl, også 500. Du får altid en `*APIError` med `Code` og `Details`, som du kan forgrene på med `errors.As`:

| Status | `Code` | Hvad klienten gør |
|---|---|---|
| 429 | `rate_limit_per_minute` | Venter `Retry-After` (1 til 60 s, højst 30 s ad gangen) og prøver igen, højst to gange |
| 503 | `upstream_unavailable` | Venter `Retry-After` (højst 30 s) og prøver igen |
| 429 | `quota_exceeded` | Returnerer fejlen med det samme. Der er ingen `Retry-After` |
| 401/403 | `invalid_api_key`, `key_blocked` … | Returnerer fejlen. Et nyt forsøg hjælper ikke |
| 404 | `not_found` | Returnerer fejlen. Id'et findes ikke |
| 500 | `internal_error` | Returnerer fejlen. Log den, og prøv igen senere |

`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. Prøv ikke igen. `Details` har `resets_at` (hvornår der svares igen), `upgrade_url` og `reason`. Afviste kald tælles og faktureres ikke. Se [Grænser, kvoter og 429](/guides/graenser-og-429).

## 4. Brug den

Modulet hedder `example.com/app` i eksemplet (`go mod init example.com/app`). Ret import-stien til dit eget modul:

```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` er sikker at dele mellem goroutines, fordi `http.Client` er det. Opret én ved opstart og genbrug den, så forbindelserne holdes åbne.

## Datavask: hvornår må du gemme?

`Vask` returnerer en `VaskSvar` med `Kategori` fra topniveau og `Resultater`, bedste først. Kategorien findes kun på topniveau, og der er intet score-felt:

- A: præcis match. Gem `Resultater[0].Bedste().ID`. "Strandvejen 100, 2900 Hellerup" giver A med én kandidat.
- B: præcis én sikker match med små stavefejl. Gem også den, med den officielle tekst. `Sikker()` er 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, fordi det nummer ikke har en 2. tv.

`Vaskeresultat.Afstand` er 0 ved en præcis match. `betegnelse` læser selv etage og dør. Har din formular allerede separate felter, kan du sende `vejnavn`, `husnr`, `etage`, `dør` og `postnr` som query-parametre i stedet. Se [Gem adresse-id'et](/guides/gem-adresse-id) for, hvorfor du skal gemme id'et.

## Parallelle kald

Skal du slå mange adresser op, så begræns antallet af samtidige kald. Ellers rammer du minutgrænsen:

```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()
```

## Tjek med curl

Samme reverse-opslag fra terminalen:

```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
```

## Næste skridt

- [Fejlformatet: DAWA-stier og /api/v1](/guides/fejlformat)
- [Koordinater og srid](/guides/koordinater-srid)
- Samme klient i Rust: [Rust-klient med reqwest](/guides/rust-klient)
