# A Ruby client with Net::HTTP and Faraday

> Call Danadresse from Ruby and Rails with Net::HTTP or Faraday, with timeouts, keep-alive, error codes and Retry-After on 429.
>
> https://danadresse.dk/en/guides/ruby-klient · verified 2026-11-02
This guide shows two Ruby clients for Danadresse. The first only uses the standard library (`Net::HTTP` and `JSON`) and works in scripts, Sinatra and Rails without new gems. The second uses Faraday, which many Rails projects already have. Both cover autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, timeouts, error codes and 429.

The key is read from the `DANADRESSE_API_KEY` environment variable. In Rails you can keep it in credentials and set the environment variable from there, but keep it on the server.

## 1. Net::HTTP

Save the file as `danadresse.rb`. Run it directly with `ruby danadresse.rb` and it makes six calls as a 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
```

The important parts of the class:

- **Keep-alive.** `Net::HTTP.start` opens one connection that is reused for every call. That saves a TLS handshake per call. A `Net::HTTP` connection is not thread-safe, so use one client per thread, for example in a Puma worker with `Thread.current[:danadresse] ||= Danadresse::Client.new`.
- **Timeouts.** `open_timeout: 3` and `read_timeout: 10` cap the waiting time. On a timeout the connection is dropped and a new one is opened on the next attempt.
- **Error formats.** The DAWA paths answer `{"type","title","details","code"}`, and `/api/v1/*` answers `{"error":{"code","message","details"}}`, for every error including 500. `fejl` reads both, so you can branch on `e.code` and read `e.details`.
- **Retry.** 429 and 503 with `Retry-After` (1 to 60 seconds on the per-minute limit) are retried after the given delay, at most 30 seconds and at most twice. `quota_exceeded` has no `Retry-After` and is raised immediately. Refused calls are not counted or billed.

## 2. Faraday

With Faraday 2 and `faraday-retry`, the middleware handles network errors and timeouts. 429 is handled in the class itself. The reason is that `quota_exceeded` is also a 429, and it must not be retried:

```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` parses JSON responses into a `Hash`, errors included, so `res.body["code"]` works directly.

## 3. In Rails

Create the client in one place and use it from models, jobs and 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
```

Store the address `id` rather than only the text. With the id you can always fetch the current address. See [Store the address id](/en/guides/gem-adresse-id).

## Error codes

| Status | `code` | Action |
|---|---|---|
| 401 | `missing_api_key`, `invalid_api_key` | Check the environment variable. Do not retry |
| 403 | `key_blocked`, `sandbox_scope`, `ip_not_allowed` | Fix the key or plan |
| 404 | `not_found` | The id does not exist |
| 429 | `rate_limit_per_minute` | The client waits `Retry-After` |
| 429 | `quota_exceeded` | Free: the monthly quota and the grace buffer are used up. Paid: the budget cap is reached. Never retry. `e.details["resets_at"]` says when calls are served again. See [Limits, quotas and 429](/en/guides/graenser-og-429) |
| 500 | `internal_error` | An error on our side. Log it and try again later |
| 503 | `upstream_unavailable` | A public register is down. The client waits |

## Datavask

`vask` returns the top-level category (A, B or C), the number of candidates, `afstand` and the best address. The category exists only at the top level, and there is no score field. A is an exact match. B is exactly one safe match with small spelling differences: store it too, with the official text. `sikker?` is true for A and B. C is uncertain: show the candidates and let the user pick. In the example, "Strandvejen 100, 2900 Hellerup" gives A with one candidate, and "Rentemestervej 8, 2. tv, 2400 København NV" gives C with seven, because that number has no 2. tv. `betegnelse` reads floor and door itself.

Try the call from the terminal:

```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)}'
```

## Next steps

- [The error format: DAWA paths and /api/v1](/en/guides/fejlformat)
- [Limits, quotas and 429](/en/guides/graenser-og-429)
- The same client in Python: [Python client](/en/guides/python-klient)
