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

Unexpected Frankfurter response shape (expected an array)

Error message

Unexpected Frankfurter response shape (expected an array)

What it means

Raised as Provider::Frankfurter::Error when GET /rates (the timeseries path used by fetch_exchange_rates) returns a body that is not a top-level Array. Frankfurter v2's /rates returns a flat array of {date, base, quote, rate} records, one per calendar day in the range; a Hash or String body is a contract violation and the provider refuses to map it. Distinct from the single-rate /route shape, which expects a Hash.

Source

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

        faraday.request :json
        faraday.response :raise_error
        faraday.headers["Accept"] = "application/json"
      end
    end

    def generate_same_currency_rates(from, to, start_date, end_date)
      (start_date..end_date).map do |date|
        Rate.new(date: date, from: from, to: to, rate: 1.0)
      end
    end

    # GET /rates?base=...&quotes=...&from=...&to=... -> a flat array of
    # { date:, base:, quote:, rate: } records, one per day in range (v2
    # carries forward weekends/holidays itself, so every calendar day in the
    # range is present, not just trading days).
    def exchange_rates(from, to, start_date, end_date)
      body = get_json("/rates", "base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s)
      raise Error, "Unexpected Frankfurter response shape (expected an array)" unless body.is_a?(Array)

      body.filter_map do |entry|
        next nil unless entry.is_a?(Hash) && entry["quote"] == to

        rate_value = entry["rate"]
        next nil if rate_value.nil?

        Rate.new(date: Date.parse(entry["date"].to_s), from: from, to: to, rate: rate_value.to_f)
      end.sort_by(&:date)
    rescue Date::Error => e
      raise Error, "Invalid date in Frankfurter response: #{e.message}"
    end
end

View on GitHub (pinned to e69894adb9)

Solutions

  1. curl the timeseries endpoint and inspect the top-level type: curl "https://api.frankfurter.dev/v2/rates?base=USD&quotes=EUR&from=2025-01-01&to=2025-01-31"
  2. If you see {"rates": {...}} style envelopes, your base URL is on v1 — set FRANKFURTER_URL to the v2 host/path
  3. Check the Frankfurter changelog for /rates format changes and pin to the documented version
  4. Log the received body class/excerpt in the error to distinguish version skew from gateway noise
  5. If a mirror is required, patch its route to the v2 flat-array shape or adapt the parser deliberately

Example fix

# before: generic array expectation
raise Error, "Unexpected Frankfurter response shape (expected an array)" unless body.is_a?(Array)

# after: accept documented v2 array, otherwise fail with body context
unless body.is_a?(Array)
  raise Error, "Unexpected Frankfurter response shape (expected an array, got #{body.class}): #{body.to_s.truncate(200)}"
end
Defensive patterns

Strategy: type-guard

Validate before calling

# Ensure you are on the v2 host before issuing timeseries requests
base = ENV["FRANKFURTER_URL"].presence || "https://api.frankfurter.dev/v2"
raise "Frankfurter v2 base URL required for /rates" unless base.end_with?("/v2")

Type guard

# Narrow the timeseries payload element-wise
def valid_rates_array?(body)
  body.is_a?(Array) && body.first(3).all? { |e| e.is_a?(Hash) && e["rate"].present? && e["date"].present? }
end

Try / catch

begin
  rates = provider.fetch_exchange_rates(from:, to:, start_date:, end_date:)
rescue Provider::Frankfurter::Error => e
  raise unless e.message.include?("expected an array")
  notify_ops("Frankfurter /rates no longer returns an array — check API version")
  []
end

Prevention

When it happens

Trigger: Calling fetch_exchange_rates(from:, to:, start_date:, end_date:) (distinct currencies) when /rates returns an object — typically a v1-style envelope {amount, base, start_date, rates: {...}}, an error object, or a proxy-injected body. Also triggered by FRANKFURTER_URL pointing at an older Frankfurter version whose /rates returns the nested rates-of-days shape.

Common situations: Migrating from Frankfurter v1 to v2 without updating FRANKFURTER_URL, self-hosted mirrors pinned to the old API, upstream format regressions, and reverse proxies serving JSON error objects with HTTP 200.

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/6466abe8ae37f3cf. Report an issue: GitHub.