we-promise/sure · error · Provider::Frankfurter::Error

Unexpected Frankfurter response shape

Error message

Unexpected Frankfurter response shape

What it means

Raised as Provider::Frankfurter::Error when GET /rate/<from>/<to>?date=... returns a body that is not a Hash or has no truthy 'rate' key. The provider expects Frankfurter v2's single-rate shape {date, base, quote, rate}; anything else (array, string, hash without rate) is a contract violation. This is the single-rate path used by fetch_exchange_rate; the same base currency==quote currency shortcut bypasses the network entirely.

Source

Thrown at app/models/provider/frankfurter.rb:54

  def usage
    with_provider_response do
      UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)")
    end
  end

  # GET /rate/{base}/{quote}?date=... -> { date:, base:, quote:, rate: }.
  # Frankfurter carries forward weekends/holidays itself, so the returned
  # date may differ from the requested one but is never simply missing.
  def fetch_exchange_rate(from:, to:, date:)
    from = sanitize_currency(from)
    to = sanitize_currency(to)

    with_provider_response do
      if from == to
        Rate.new(date: date, from: from, to: to, rate: 1.0)
      else
        body = get_json("/rate/#{from}/#{to}", "date" => date.to_s)
        raise Error, "Unexpected Frankfurter response shape" unless body.is_a?(Hash) && body["rate"]

        begin
          parsed_date = Date.parse(body["date"].to_s)
        rescue Date::Error => e
          raise Error, "Invalid date in Frankfurter response: #{e.message}"
        end

        Rate.new(date: parsed_date, from: from, to: to, rate: body["rate"].to_f)
      end
    end
  end

  def fetch_exchange_rates(from:, to:, start_date:, end_date:)
    from = sanitize_currency(from)
    to = sanitize_currency(to)

    with_provider_response do
      if from == to

View on GitHub (pinned to e69894adb9)

Solutions

  1. Reproduce: curl "https://api.frankfurter.dev/v2/rate/USD/EUR?date=2025-01-02" and verify the JSON has date/base/quote/rate
  2. Confirm FRANKFURTER_URL ends with the correct version path (/v2) for the expected response shape
  3. Check that both currencies are in GET /currencies — ECB covers a fixed set; exotic pairs will never resolve
  4. If a mirror is in use, switch back to the official host to rule out divergent API versions
  5. Log the raw body on failure so unsupported-pair responses are distinguishable from format changes

Example fix

# before: opaque shape failure
raise Error, "Unexpected Frankfurter response shape" unless body.is_a?(Hash) && body["rate"]

# after: include the pair and body excerpt for diagnosis
unless body.is_a?(Hash) && body["rate"]
  raise Error, "Unexpected Frankfurter response shape for #{from}/#{to}: #{body.to_s.truncate(200)}"
end
Defensive patterns

Strategy: type-guard

Validate before calling

# Pre-check that the pair is supported before requesting a rate
def frankfurter_supports?(cur)
  SUPPORTED = provider.send(:get_json, "/currencies").keys.map(&:upcase)
  SUPPORTED.include?(cur.upcase)
end

return Rate.new(date:, from:, to:, rate: 1.0) unless frankfurter_supports?(from) && frankfurter_supports?(to)

Type guard

# Narrow a Frankfurter single-rate body before use
def valid_rate_body?(body)
  body.is_a?(Hash) && body["rate"].is_a?(Numeric) && body["date"].is_a?(String)
end

Try / catch

begin
  rate = provider.fetch_exchange_rate(from: "USD", to: "EUR", date: date)
rescue Provider::Frankfurter::Error => e
  raise unless e.message.include?("Unexpected Frankfurter response shape")
  notify_ops("Frankfurter rate shape drift for USD/EUR")
  nil
end

Prevention

When it happens

Trigger: Calling fetch_exchange_rate(from:, to:, date:) with two distinct sanitized currencies where the response body is an error envelope, an HTML page parsed into an odd shape, or a Frankfurter API version change (e.g. FRANKFURTER_URL pointing at v1 whose /rate response shape differs). Also fires if the pair is unsupported and the service returns JSON without a 'rate' field.

Common situations: Switching FRANKFURTER_URL between api.frankfurter.dev versions or self-hosted mirrors with different route shapes, requesting exotic currency pairs Frankfurter (ECB data) does not cover, gateways returning JSON status objects, and reverse proxies serving error pages with 200 status.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/4e9013801418241c. Report an issue: GitHub.