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=..."es=...&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
- curl the timeseries endpoint and inspect the top-level type: curl "https://api.frankfurter.dev/v2/rates?base=USD"es=EUR&from=2025-01-01&to=2025-01-31"
- If you see {"rates": {...}} style envelopes, your base URL is on v1 — set FRANKFURTER_URL to the v2 host/path
- Check the Frankfurter changelog for /rates format changes and pin to the documented version
- Log the received body class/excerpt in the error to distinguish version skew from gateway noise
- 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
- Pin FRANKFURTER_URL to the versioned v2 endpoint in every environment
- Smoke-test the /rates array shape at deploy time in staging
- Never mix v1 and v2 hosts across envs — the two shapes are mutually incompatible
- Log body class on shape errors so version skew is obvious in logs
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
- Unexpected Frankfurter response shape
- Invalid date in Frankfurter response: #{e.message}
- Unexpected response format from search API
- Unexpected response format from EOD API
- Frankfurter currencies endpoint returned no data
AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21).
Data as JSON: /api/errors/6466abe8ae37f3cf.
Report an issue: GitHub.