# En Ruby-klient med Net::HTTP og Faraday

> Kald Danadresse fra Ruby og Rails med Net::HTTP eller Faraday, med timeouts, keep-alive, fejlkoder og Retry-After ved 429.
>
> https://danadresse.dk/guides/ruby-klient · verified 2026-11-02
Denne guide viser to Ruby-klienter til Danadresse. Den første bruger kun standardbiblioteket (`Net::HTTP` og `JSON`) og virker i scripts, Sinatra og Rails uden nye gems. Den anden bruger Faraday, som mange Rails-projekter allerede har. Begge dækker autocomplete, opslag på id, datavask, reverse geokodning, timeouts, fejlkoder og 429.

Nøglen læses fra miljøvariablen `DANADRESSE_API_KEY`. I Rails kan du lægge den i credentials og sætte miljøvariablen derfra, men hold den på serveren.

## 1. Net::HTTP

Gem filen som `danadresse.rb`. Kører du den direkte med `ruby danadresse.rb`, laver den seks kald som demo:

```ruby id=nethttp run=no
# danadresse.rb: en lille klient til Danadresse API med Net::HTTP (kun standardbiblioteket)
require "json"
require "net/http"
require "uri"

module Danadresse
  Adresse = Struct.new(:id, :betegnelse, :postnr, :lon, :lat, keyword_init: true)
  # kategori kun på topniveau (ingen score); afstand 0 = præcis match
  Vask = Struct.new(:kategori, :kandidater, :adresse_id, :tekst, :afstand, keyword_init: true) do
    # A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge
    def sikker?
      %w[A B].include?(kategori) && !adresse_id.nil?
    end
  end

  class Error < StandardError
    attr_reader :status, :code, :retry_after, :details

    def initialize(status, code, message, retry_after = nil, details = nil)
      super("HTTP #{status} #{code}: #{message}")
      @status = status
      @code = code # stabil kode, fx "invalid_api_key"
      @retry_after = retry_after
      @details = details # fx resets_at og upgrade_url ved quota_exceeded
    end
  end

  class Client
    MAX_RETRIES = 2

    def initialize(api_key: ENV.fetch("DANADRESSE_API_KEY"),
                   base_url: ENV.fetch("DANADRESSE_BASE_URL", "https://api.danadresse.dk"))
      @api_key = api_key
      @base = URI(base_url)
    end

    def autocomplete(q, per_side: 5)
      get("/autocomplete", q: q, per_side: per_side)
    end

    def adresse(id)
      d = get("/adresser/#{URI.encode_www_form_component(id)}")
      til_adresse(d, d["adgangsadresse"])
    end

    def vask(betegnelse)
      svar = get("/datavask/adresser", betegnelse: betegnelse)
      res = svar["resultater"] || []
      # aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt)
      a = res.dig(0, "aktueladresse") || res.dig(0, "adresse")
      Vask.new(kategori: svar["kategori"], kandidater: res.size, adresse_id: a&.dig("id"),
               tekst: a && "#{a['vejnavn']} #{a['husnr']}, #{a['postnr']} #{a['postnrnavn']}",
               afstand: res.dig(0, "vaskeresultat", "afstand"))
    end

    def reverse(lon, lat)
      d = get("/adgangsadresser/reverse", x: lon, y: lat)
      til_adresse(d, d)
    end

    private

    def til_adresse(d, ag)
      lon, lat = ag.dig("adgangspunkt", "koordinater")
      Adresse.new(id: d["id"], betegnelse: d["adressebetegnelse"],
                  postnr: ag.dig("postnummer", "nr"), lon: lon, lat: lat)
    end

    def get(path, params = {})
      uri = @base.dup
      uri.path = path
      uri.query = URI.encode_www_form(params) unless params.empty?
      attempt = 0
      begin
        res = http.request(Net::HTTP::Get.new(uri, "X-Api-Key" => @api_key, "Accept" => "application/json"))
        return JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess)

        raise fejl(res)
      rescue Error => e
        # 429 (minutgrænse) og 503 med Retry-After kan prøves igen; quota_exceeded har ingen Retry-After
        raise unless [429, 503].include?(e.status) && e.retry_after && attempt < MAX_RETRIES

        attempt += 1
        sleep([e.retry_after, 30].min)
        retry
      rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED
        raise if attempt >= MAX_RETRIES

        attempt += 1
        @http = nil # åbn en ny forbindelse
        sleep(2**attempt)
        retry
      end
    end

    # Én keep-alive-forbindelse pr. klient (ikke trådsikker: brug én klient pr. tråd)
    def http
      @http ||= Net::HTTP.start(@base.host, @base.port, use_ssl: @base.scheme == "https",
                                                        open_timeout: 3, read_timeout: 10)
    end

    def fejl(res)
      body = JSON.parse(res.body) rescue {}
      body = {} unless body.is_a?(Hash)
      # DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
      err = body["error"].is_a?(Hash) ? body["error"] : body
      ra = res["Retry-After"]
      Error.new(res.code.to_i, err["code"], err["title"] || err["message"] || res.message,
                ra&.match?(/\A\d+\z/) ? ra.to_i : nil, err["details"])
    end
  end
end

if $PROGRAM_NAME == __FILE__
  api = Danadresse::Client.new
  api.autocomplete("Rådhuspladsen 1, 4200", per_side: 3).each { |f| puts "forslag: #{f['forslagstekst']}" }
  a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018")
  puts "opslag: #{a.betegnelse} #{a.lon} #{a.lat}"
  ["Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"].each do |tekst|
    v = api.vask(tekst)
    puts "datavask: #{v.kategori} #{v.kandidater} #{v.sikker? ? v.tekst : 'lad brugeren vælge'}"
  end
  puts "reverse: #{api.reverse(12.5683, 55.6761).betegnelse}"
  begin
    api.adresse("00000000-0000-0000-0000-000000000000")
  rescue Danadresse::Error => e
    puts "fejl: #{e.status} #{e.code}"
  end
end
```

Output:

```text id=nethttp-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 Strandvejen 100, 2900 Hellerup
datavask: C 7 lad brugeren vælge
reverse: Rådhuspladsen 3, 1550 København V
fejl: 404 not_found
```

Det vigtigste i klassen:

- **Keep-alive.** `Net::HTTP.start` åbner én forbindelse, som genbruges til alle kald. Det sparer et TLS-håndtryk pr. kald. En `Net::HTTP`-forbindelse er ikke trådsikker, så brug én klient pr. tråd, fx i en Puma-worker med `Thread.current[:danadresse] ||= Danadresse::Client.new`.
- **Timeouts.** `open_timeout: 3` og `read_timeout: 10` sætter loft over ventetiden. Ved timeout lukkes forbindelsen, og der åbnes en ny ved næste forsøg.
- **Fejlformater.** DAWA-stierne svarer `{"type","title","details","code"}`, og `/api/v1/*` svarer `{"error":{"code","message","details"}}`, for alle fejl, også 500. `fejl` læser begge, så du kan forgrene på `e.code` og læse `e.details`.
- **Retry.** 429 og 503 med `Retry-After` (1 til 60 sekunder ved minutgrænsen) prøves igen efter den angivne ventetid, højst 30 sekunder og højst to gange. `quota_exceeded` har ingen `Retry-After` og rejses med det samme. Afviste kald tælles og faktureres ikke.

## 2. Faraday

Med Faraday 2 og `faraday-retry` klarer middleware'en netværksfejl og timeouts. 429 håndteres i klassen selv. Grunden er, at `quota_exceeded` også er en 429, og den skal ikke gentages:

```ruby id=faraday run=no
# danadresse_faraday.rb (Gemfile: gem "faraday", "~> 2.0"; gem "faraday-retry")
require "faraday"
require "faraday/retry"

module Danadresse
  class Error < StandardError
    attr_reader :status, :code, :retry_after, :details

    def initialize(status, code, message, retry_after = nil, details = nil)
      super("HTTP #{status} #{code}: #{message}")
      @status = status
      @code = code
      @retry_after = retry_after
      @details = details
    end
  end

  class FaradayClient
    MAX_RETRIES = 2

    def initialize(api_key: ENV.fetch("DANADRESSE_API_KEY"),
                   base_url: ENV.fetch("DANADRESSE_BASE_URL", "https://api.danadresse.dk"))
      @conn = Faraday.new(url: base_url, headers: { "X-Api-Key" => api_key }) do |f|
        f.options.open_timeout = 3
        f.options.timeout = 10
        # Kun netværksfejl og timeouts; 429 håndteres nedenfor, så quota_exceeded ikke gentages
        f.request :retry, max: MAX_RETRIES, interval: 0.5, backoff_factor: 2,
                          exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
        f.response :json
      end
    end

    def autocomplete(q, per_side: 5)
      get("/autocomplete", q: q, per_side: per_side)
    end

    def adresse(id)
      get("/adresser/#{id}")
    end

    def vask(betegnelse)
      get("/datavask/adresser", betegnelse: betegnelse)
    end

    def reverse(lon, lat)
      get("/adgangsadresser/reverse", x: lon, y: lat)
    end

    private

    def get(path, params = {})
      attempt = 0
      loop do
        res = @conn.get(path, params)
        return res.body if res.success?

        body = res.body.is_a?(Hash) ? res.body : {}
        # DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
        err = body["error"].is_a?(Hash) ? body["error"] : body
        ra = res.headers["retry-after"]
        retry_after = ra&.match?(/\A\d+\z/) ? ra.to_i : nil
        if [429, 503].include?(res.status) && retry_after && attempt < MAX_RETRIES
          attempt += 1
          sleep([retry_after, 30].min)
          next
        end
        raise Error.new(res.status, err["code"], err["title"] || err["message"] || "HTTP #{res.status}",
                        retry_after, err["details"])
      end
    end
  end
end
```

`f.response :json` parser JSON-svar til en `Hash`, også ved fejl, så `res.body["code"]` virker direkte.

## 3. I Rails

Opret klienten ét sted og brug den fra modeller, jobs og controllers:

```ruby id=rails run=no
# config/initializers/danadresse.rb
require Rails.root.join("lib/danadresse")

# app/models/kunde.rb
class Kunde < ApplicationRecord
  validate :adresse_findes, if: :adresse_id_changed?

  private

  def adresse_findes
    Danadresse::Client.new.adresse(adresse_id)
  rescue Danadresse::Error => e
    errors.add(:adresse_id, "findes ikke") if e.code == "not_found"
    raise unless e.code == "not_found"
  end
end
```

Gem adressens `id` i stedet for kun teksten. Med id'et kan du altid hente den aktuelle adresse. Se [Gem adresse-id'et](/guides/gem-adresse-id).

## Fejlkoder

| Status | `code` | Handling |
|---|---|---|
| 401 | `missing_api_key`, `invalid_api_key` | Tjek miljøvariablen. Prøv ikke igen |
| 403 | `key_blocked`, `sandbox_scope`, `ip_not_allowed` | Ret nøglen eller planen |
| 404 | `not_found` | Id'et findes ikke |
| 429 | `rate_limit_per_minute` | Klienten venter `Retry-After` |
| 429 | `quota_exceeded` | Gratis: månedskvoten og bufferen er brugt. Betalt: budgetloftet er nået. Prøv aldrig igen. `e.details["resets_at"]` fortæller, hvornår der svares igen. Se [Grænser, kvoter og 429](/guides/graenser-og-429) |
| 500 | `internal_error` | En fejl hos os. Log den, og prøv igen senere |
| 503 | `upstream_unavailable` | Et offentligt register er nede. Klienten venter |

## Datavask

`vask` returnerer kategorien fra topniveau (A, B eller C), antallet af kandidater, `afstand` og den bedste adresse. Kategorien findes kun på topniveau, og der er intet score-felt. A er en præcis match. B er præcis én sikker match med små stavefejl: gem også den, med den officielle tekst. `sikker?` er sand for A og B. C er usikker: vis kandidaterne, og lad brugeren vælge. I eksemplet giver "Strandvejen 100, 2900 Hellerup" A med én kandidat, og "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv, fordi det nummer ikke har en 2. tv. `betegnelse` læser selv etage og dør.

Prøv kaldet fra terminalen:

```bash id=curl-check run=live expect="kategori"
curl -s -G "https://api.danadresse.dk/datavask/adresser" \
  --data-urlencode "betegnelse=Strandvejen 100, 2900 Hellerup" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" | jq '{kategori, kandidater: (.resultater | length)}'
```

## Næste skridt

- [Fejlformatet: DAWA-stier og /api/v1](/guides/fejlformat)
- [Grænser, kvoter og 429](/guides/graenser-og-429)
- Samme klient i Python: [Python-klient](/guides/python-klient)
