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.

5 min Niveau: Øvet ✓ Kode testet

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#client
// 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:

StatusCodeHvad klienten gør
429rate_limit_per_minuteVenter Retry-After (1 til 60 s, højst 30 s ad gangen) og prøver igen, højst to gange
503upstream_unavailableVenter Retry-After (højst 30 s) og prøver igen
429quota_exceededReturnerer fejlen med det samme. Der er ingen Retry-After
401/403invalid_api_key, key_blocked …Returnerer fejlen. Et nyt forsøg hjælper ikke
404not_foundReturnerer fejlen. Id'et findes ikke
500internal_errorReturnerer 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.

4. Brug den

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

Go#main
// 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#output
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 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#parallel
// 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:

Shell#curl-check
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

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →