{"record":{"id":"4e9013801418241c","repo":"we-promise/sure","slug":"unexpected-frankfurter-response-shape","errorCode":null,"errorMessage":"Unexpected Frankfurter response shape","messagePattern":"Unexpected Frankfurter response shape","errorType":"exception","errorClass":"Provider::Frankfurter::Error","httpStatus":null,"severity":"error","filePath":"app/models/provider/frankfurter.rb","lineNumber":54,"sourceCode":"  def usage\n    with_provider_response do\n      UsageData.new(used: nil, limit: nil, utilization: nil, plan: \"Free (no key required)\")\n    end\n  end\n\n  # GET /rate/{base}/{quote}?date=... -> { date:, base:, quote:, rate: }.\n  # Frankfurter carries forward weekends/holidays itself, so the returned\n  # date may differ from the requested one but is never simply missing.\n  def fetch_exchange_rate(from:, to:, date:)\n    from = sanitize_currency(from)\n    to = sanitize_currency(to)\n\n    with_provider_response do\n      if from == to\n        Rate.new(date: date, from: from, to: to, rate: 1.0)\n      else\n        body = get_json(\"/rate/#{from}/#{to}\", \"date\" => date.to_s)\n        raise Error, \"Unexpected Frankfurter response shape\" unless body.is_a?(Hash) && body[\"rate\"]\n\n        begin\n          parsed_date = Date.parse(body[\"date\"].to_s)\n        rescue Date::Error => e\n          raise Error, \"Invalid date in Frankfurter response: #{e.message}\"\n        end\n\n        Rate.new(date: parsed_date, from: from, to: to, rate: body[\"rate\"].to_f)\n      end\n    end\n  end\n\n  def fetch_exchange_rates(from:, to:, start_date:, end_date:)\n    from = sanitize_currency(from)\n    to = sanitize_currency(to)\n\n    with_provider_response do\n      if from == to","sourceCodeStart":36,"sourceCodeEnd":72,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/frankfurter.rb#L36-L72","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Reproduce: curl \"https://api.frankfurter.dev/v2/rate/USD/EUR?date=2025-01-02\" and verify the JSON has date/base/quote/rate","Confirm FRANKFURTER_URL ends with the correct version path (/v2) for the expected response shape","Check that both currencies are in GET /currencies — ECB covers a fixed set; exotic pairs will never resolve","If a mirror is in use, switch back to the official host to rule out divergent API versions","Log the raw body on failure so unsupported-pair responses are distinguishable from format changes"],"exampleFix":"# before: opaque shape failure\nraise Error, \"Unexpected Frankfurter response shape\" unless body.is_a?(Hash) && body[\"rate\"]\n\n# after: include the pair and body excerpt for diagnosis\nunless body.is_a?(Hash) && body[\"rate\"]\n  raise Error, \"Unexpected Frankfurter response shape for #{from}/#{to}: #{body.to_s.truncate(200)}\"\nend","handlingStrategy":"type-guard","validationCode":"# Pre-check that the pair is supported before requesting a rate\ndef frankfurter_supports?(cur)\n  SUPPORTED = provider.send(:get_json, \"/currencies\").keys.map(&:upcase)\n  SUPPORTED.include?(cur.upcase)\nend\n\nreturn Rate.new(date:, from:, to:, rate: 1.0) unless frankfurter_supports?(from) && frankfurter_supports?(to)","typeGuard":"# Narrow a Frankfurter single-rate body before use\ndef valid_rate_body?(body)\n  body.is_a?(Hash) && body[\"rate\"].is_a?(Numeric) && body[\"date\"].is_a?(String)\nend","tryCatchPattern":"begin\n  rate = provider.fetch_exchange_rate(from: \"USD\", to: \"EUR\", date: date)\nrescue Provider::Frankfurter::Error => e\n  raise unless e.message.include?(\"Unexpected Frankfurter response shape\")\n  notify_ops(\"Frankfurter rate shape drift for USD/EUR\")\n  nil\nend","preventionTips":["Validate currencies against GET /currencies before requesting rates for exotic pairs","Keep FRANKFURTER_URL version-pinned (/v2) — v1 has a different /rate shape","Distinguish 'unsupported pair' from 'API changed' by logging bodies on shape failures","For USD/EUR-style majors, cache rates; only edge pairs risk shape surprises"],"tags":["frankfurter","fx-rates","schema-validation","json","api-contract"],"backgroundTag":"schema-validation-failed","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}