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.

5 min Level: Intermediate ✓ Code tested

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#nethttp
# 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#nethttp-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 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#faraday
# 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#rails
# 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.

Error codes

StatuscodeAction
401missing_api_key, invalid_api_keyCheck the environment variable. Do not retry
403key_blocked, sandbox_scope, ip_not_allowedFix the key or plan
404not_foundThe id does not exist
429rate_limit_per_minuteThe client waits Retry-After
429quota_exceededFree: 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
500internal_errorAn error on our side. Log it and try again later
503upstream_unavailableA 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:

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

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →