{"record":{"id":"6466abe8ae37f3cf","repo":"we-promise/sure","slug":"unexpected-frankfurter-response-shape-expected-an","errorCode":null,"errorMessage":"Unexpected Frankfurter response shape (expected an array)","messagePattern":"Unexpected Frankfurter response shape \\(expected an array\\)","errorType":"exception","errorClass":"Provider::Frankfurter::Error","httpStatus":null,"severity":"error","filePath":"app/models/provider/frankfurter.rb","lineNumber":138,"sourceCode":"        faraday.request :json\n        faraday.response :raise_error\n        faraday.headers[\"Accept\"] = \"application/json\"\n      end\n    end\n\n    def generate_same_currency_rates(from, to, start_date, end_date)\n      (start_date..end_date).map do |date|\n        Rate.new(date: date, from: from, to: to, rate: 1.0)\n      end\n    end\n\n    # GET /rates?base=...&quotes=...&from=...&to=... -> a flat array of\n    # { date:, base:, quote:, rate: } records, one per day in range (v2\n    # carries forward weekends/holidays itself, so every calendar day in the\n    # range is present, not just trading days).\n    def exchange_rates(from, to, start_date, end_date)\n      body = get_json(\"/rates\", \"base\" => from, \"quotes\" => to, \"from\" => start_date.to_s, \"to\" => end_date.to_s)\n      raise Error, \"Unexpected Frankfurter response shape (expected an array)\" unless body.is_a?(Array)\n\n      body.filter_map do |entry|\n        next nil unless entry.is_a?(Hash) && entry[\"quote\"] == to\n\n        rate_value = entry[\"rate\"]\n        next nil if rate_value.nil?\n\n        Rate.new(date: Date.parse(entry[\"date\"].to_s), from: from, to: to, rate: rate_value.to_f)\n      end.sort_by(&:date)\n    rescue Date::Error => e\n      raise Error, \"Invalid date in Frankfurter response: #{e.message}\"\n    end\nend\n","sourceCodeStart":120,"sourceCodeEnd":152,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/frankfurter.rb#L120-L152","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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\"","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"],"exampleFix":"# before: generic array expectation\nraise Error, \"Unexpected Frankfurter response shape (expected an array)\" unless body.is_a?(Array)\n\n# after: accept documented v2 array, otherwise fail with body context\nunless body.is_a?(Array)\n  raise Error, \"Unexpected Frankfurter response shape (expected an array, got #{body.class}): #{body.to_s.truncate(200)}\"\nend","handlingStrategy":"type-guard","validationCode":"# Ensure you are on the v2 host before issuing timeseries requests\nbase = ENV[\"FRANKFURTER_URL\"].presence || \"https://api.frankfurter.dev/v2\"\nraise \"Frankfurter v2 base URL required for /rates\" unless base.end_with?(\"/v2\")","typeGuard":"# Narrow the timeseries payload element-wise\ndef valid_rates_array?(body)\n  body.is_a?(Array) && body.first(3).all? { |e| e.is_a?(Hash) && e[\"rate\"].present? && e[\"date\"].present? }\nend","tryCatchPattern":"begin\n  rates = provider.fetch_exchange_rates(from:, to:, start_date:, end_date:)\nrescue Provider::Frankfurter::Error => e\n  raise unless e.message.include?(\"expected an array\")\n  notify_ops(\"Frankfurter /rates no longer returns an array — check API version\")\n  []\nend","preventionTips":["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"],"tags":["frankfurter","fx-rates","schema-validation","timeseries","api-contract"],"backgroundTag":"schema-validation-failed","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}