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.
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:
// 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
contextyou 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.
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:
// 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 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 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:
// 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:
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
- Coordinates and srid
- The same client in Rust: Rust client with reqwest
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)