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.
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:
// 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.
4. Brug den
Modulet hedder example.com/app i eksemplet (go mod init example.com/app). Ret import-stien til dit eget modul:
// 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 } }
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:
// 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:
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
- Koordinater og srid
- Samme klient i Rust: Rust-klient med reqwest
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)