{"record":{"id":"c6d8bcb98a009ca0","repo":"we-promise/sure","slug":"unexpected-response-format-from-search-endpoint-c6d8bc","errorCode":null,"errorMessage":"Unexpected response format from search endpoint","messagePattern":"Unexpected response format from search endpoint","errorType":"exception","errorClass":"Provider::Tiingo::Error","httpStatus":null,"severity":"warning","filePath":"app/models/provider/tiingo.rb","lineNumber":83,"sourceCode":"  end\n\n  # ================================\n  #           Securities\n  # ================================\n\n  def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)\n    with_provider_response do\n      throttle_request\n\n      response = client.get(\"#{base_url}/tiingo/utilities/search\") do |req|\n        req.params[\"query\"] = symbol\n      end\n\n      parsed = JSON.parse(response.body)\n      check_api_error!(parsed)\n\n      unless parsed.is_a?(Array)\n        raise Error, \"Unexpected response format from search endpoint\"\n      end\n\n      # Tiingo's daily-price endpoints are looked up by ticker alone, so every\n      # result sharing a ticker resolves to the same priced entry (see\n      # best_match_for_ticker) and therefore the same currency. Resolve it once\n      # per unique ticker and reuse it both for caching (so fetch_security_prices\n      # can use it without a second search request) and for the Security objects\n      # below, so what's shown in search results always matches what\n      # fetch_security_prices will later return.\n      matches_by_ticker = parsed.filter_map { |security| security[\"ticker\"] }.map(&:upcase).uniq.index_with do |ticker|\n        best_match_for_ticker(parsed, ticker)\n      end\n\n      currency_by_ticker = matches_by_ticker.transform_values { |match| currency_for_country(match&.dig(\"countryCode\")) }\n\n      currency_by_ticker.each do |ticker, currency|\n        next if currency.blank?\n","sourceCodeStart":65,"sourceCodeEnd":101,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/tiingo.rb#L65-L101","documentation":"search_securities GETs /tiingo/utilities/search and expects a JSON array of match objects. After JSON.parse and check_api_error! (which only handles Hash payloads containing a 'detail' key), any non-Array payload raises Error 'Unexpected response format from search endpoint'. So Tiingo returned valid JSON that is neither a result list nor a detail-bearing error object -- typically an upstream format change, a maintenance/error object without 'detail', or a proxy/WAF interstitial returning JSON.","triggerScenarios":"Calling search_securities(symbol) when Tiingo's search endpoint responds with a JSON object instead of a list -- e.g. an undocumented error shape like {'Error': '...'}, an account-level message, or an API version change. Distinguished from API errors by check_api_error! having already passed (no 'detail' key present).","commonSituations":"Tiingo shipping a response-shape change; symbol query hitting an edge case (empty/odd characters) that returns an object; intermediary (corporate proxy, Cloudflare) injecting a JSON block page; API key tier mismatch returning an object without 'detail'.","solutions":["Inspect the raw body: reproduce in console with client.get and log response.body to see the actual payload shape","If the payload is an error object, extend check_api_error! to recognize that key (as was done for 'detail')","Report/check Tiingo API changelog if the search endpoint legitimately changed shape; treat as transient and retry once before surfacing"],"exampleFix":"# before\nparsed = JSON.parse(response.body)\ncheck_api_error!(parsed)\nraise Error, 'Unexpected response format from search endpoint' unless parsed.is_a?(Array)\n\n# after (capture the payload for diagnosis)\nparsed = JSON.parse(response.body)\ncheck_api_error!(parsed)\nunless parsed.is_a?(Array)\n  Rails.logger.warn(\"Tiingo search non-array response: #{parsed.inspect}\")\n  raise Error, \"Unexpected response format from search endpoint: #{parsed.inspect}\"[0, 200]\nend","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"def tiingo_search_payload?(parsed)\n  parsed.is_a?(Array)\nend","tryCatchPattern":"begin\n  results = provider.search_securities('AAPL')\nrescue Provider::Tiingo::Error => e\n  Rails.logger.warn(\"Tiingo search unavailable: #{e.message}\")\n  results = Provider::Response.new(data: [], error: nil)\nend","preventionTips":["Log raw response bodies for non-array payloads so format changes are diagnosed from the first occurrence","Keep search-dependent flows degradable (empty results) instead of fatal"],"tags":["tiingo","api-response","json","search","provider-data"],"backgroundTag":"unexpected-api-response-format","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}