{"record":{"id":"d61b6fd87f376aee","repo":"we-promise/sure","slug":"invalid-frankfurter-response-e-message","errorCode":null,"errorMessage":"Invalid Frankfurter response: #{e.message}","messagePattern":"Invalid Frankfurter response: #(.+?)","errorType":"exception","errorClass":"Provider::Frankfurter::Error","httpStatus":null,"severity":"error","filePath":"app/models/provider/frankfurter.rb","lineNumber":104,"sourceCode":"    # from/to are interpolated directly into the URL path in\n    # fetch_exchange_rate (GET /rate/{from}/{to}), so strip anything that\n    # isn't a letter before use - real ISO 4217 codes are always A-Z anyway.\n    def sanitize_currency(code)\n      code.to_s.upcase.gsub(/[^A-Z]/, \"\")\n    end\n\n    def base_url\n      ENV[\"FRANKFURTER_URL\"].presence || \"https://api.frankfurter.dev/v2\"\n    end\n\n    def get_json(path, params = {})\n      throttle_request\n      response = client.get(\"#{base_url}#{path}\") do |req|\n        params.each { |k, v| req.params[k] = v }\n      end\n      JSON.parse(response.body)\n    rescue JSON::ParserError => e\n      raise Error, \"Invalid Frankfurter response: #{e.message}\"\n    end\n\n    def client\n      @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|\n        faraday.options.open_timeout = 5\n        faraday.options.timeout      = 20\n\n        faraday.request(:retry, {\n          max: 3,\n          interval: 0.5,\n          interval_randomness: 0.5,\n          backoff_factor: 2,\n          exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ]\n        })\n\n        faraday.request :json\n        faraday.response :raise_error\n        faraday.headers[\"Accept\"] = \"application/json\"","sourceCodeStart":86,"sourceCodeEnd":122,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/frankfurter.rb#L86-L122","documentation":"Raised as Provider::Frankfurter::Error when the response body for any Frankfurter request cannot be parsed as JSON (JSON::ParserError). get_json is the shared transport for /currencies, /rate and /rates; if the server returns HTML (error page), plain text, or an empty body, JSON.parse fails and the parser error is re-raised as a provider Error with the original message. A 5-second open timeout and 20-second read timeout plus a 3-retry Faraday layer sit below this guard.","triggerScenarios":"Any Frankfurter call where the body is not valid JSON: a 502/503 HTML page from a proxy in front of the API, an empty body from a dropping connection, a rate-limit page from a mirror, or FRANKFURTER_URL pointing at a non-API host that serves a website at the same path.","commonSituations":"Transient gateway errors at frankfurter.dev (usually retried away by the Faraday middleware), misconfigured FRANKFURTER_URL (e.g. https://frankfurter.dev without /v2, returning HTML), captive portals or SSL-inspecting proxies injecting HTML, and self-hosted instances returning error pages while booting.","solutions":["Fetch the failing URL with curl -i and check Content-Type — HTML means proxy/misroute, not an API issue","Verify FRANKFURTER_URL includes the versioned base (default https://api.frankfurter.dev/v2)","If the body is an upstream 5xx page, rely on the existing Faraday retry (3 attempts, backoff) and retry the job later","If a corporate proxy intercepts HTTPS, configure Faraday's proxy or exclude the host from SSL inspection","Add the response status and a body excerpt to the raised error for faster triage"],"exampleFix":"# before: parser error message only, no context on what came back\nrescue JSON::ParserError => e\n  raise Error, \"Invalid Frankfurter response: #{e.message}\"\n\n# after: capture status + body excerpt before parsing\nbody_raw = response.body\nparsed = begin\n  JSON.parse(body_raw)\nrescue JSON::ParserError => e\n  raise Error, \"Invalid Frankfurter response (HTTP #{response.status}): #{e.message} body=#{body_raw.to_s.truncate(120)}\"\nend","handlingStrategy":"retry","validationCode":"# Cheap pre-flight that the endpoint speaks JSON before a sync run\nuri = URI(\"#{base}/currencies\")\nres = Net::HTTP.get_response(uri)\nres.content_type == \"application/json\"","typeGuard":null,"tryCatchPattern":"begin\n  rates = provider.fetch_exchange_rates(...)\nrescue Provider::Frankfurter::Error => e\n  raise unless e.message.include?(\"Invalid Frankfurter response\")\n  # HTML/empty bodies are usually transient gateway errors — back off and retry once\n  sleep 5\n  retry\nend","preventionTips":["Verify FRANKFURTER_URL points at the JSON API, not the marketing site","Keep Faraday retry middleware enabled (3 attempts with backoff) for 5xx/parse failures","Check Content-Type on responses in lower layers to fail fast on HTML error pages","Exclude the API host from SSL-inspecting proxies that mangle bodies"],"tags":["frankfurter","json","invalid-response","proxy","fx-rates"],"backgroundTag":"invalid-json-response","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}