{"record":{"id":"1705e77f93de5c4c","repo":"we-promise/sure","slug":"snaptrade-positions-response-has-no-results-array","errorCode":null,"errorMessage":"SnapTrade positions response has no results array (keys: #{response.is_a?(Hash) ? response.keys.inspect : response.class})","messagePattern":"SnapTrade positions response has no results array \\(keys: #(.+?)\\)","errorType":"exception","errorClass":"Provider::Snaptrade::ApiError","httpStatus":null,"severity":"error","filePath":"app/models/provider/snaptrade.rb","lineNumber":224,"sourceCode":"  def list_accounts\n    get_json(\"/api/v1/accounts\")\n  end\n\n  # Returns Array<Hash> of balance entries\n  def get_balances(account_id:)\n    get_json(\"/api/v1/accounts/#{account_id}/balances\")\n  end\n\n  # Returns Array<Hash> of positions\n  def get_positions(account_id:)\n    response = get_json(\"/api/v1/accounts/#{account_id}/positions/all\")\n    results = response[\"results\"] if response.is_a?(Hash)\n\n    # An empty `results` is a legitimately empty account, but a missing one is\n    # a partial or schema-changed response. Raising leaves the previous\n    # snapshot in place rather than overwriting it with nothing.\n    unless results.is_a?(Array)\n      raise ApiError.new(\n        \"SnapTrade positions response has no results array \" \\\n        \"(keys: #{response.is_a?(Hash) ? response.keys.inspect : response.class})\"\n      )\n    end\n\n    results.reject { |position| unsupported_instrument?(position) }\n  end\n\n  # Returns raw JSON: paginated form is {\"data\" => [...]}, may also be a plain Array\n  def get_account_activities(account_id:, start_date: nil, end_date: nil)\n    params = {}\n    params[:startDate] = start_date.to_date.to_s if start_date\n    params[:endDate] = end_date.to_date.to_s if end_date\n    get_json(\"/api/v1/accounts/#{account_id}/activities\", params)\n  end\n\n  # Cross-account activities endpoint. Returns Array<Hash>.\n  def get_activities(start_date: nil, end_date: nil, accounts: nil, brokerage_authorizations: nil, type: nil)","sourceCodeStart":206,"sourceCodeEnd":242,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/snaptrade.rb#L206-L242","documentation":"Raised by Provider::Snaptrade#get_positions when the /positions/all response is a Hash that lacks a 'results' Array (or is not a Hash at all). This is an intentional guard: an empty results array is a legitimately empty account, but a missing key means a partial or schema-changed response, and raising prevents the caller from overwriting the last good snapshot with an empty one. The message lists the actual response keys (or class) for diagnosis.","triggerScenarios":"SnapTrade returns an error-shaped object (e.g. {\"error\": ...} with HTTP 200), changes its pagination envelope so positions live under a different key, or returns a truncated/partial payload; the keys in the message reveal which shape arrived.","commonSituations":"SnapTrade ships an API version that renames the results field; a gateway returns a 200 with an error body during partial outage; instrument filtering assumptions break after the provider adds a new top-level field and drops another.","solutions":["Read the keys in the message - an 'error' key means a masked upstream failure; unfamiliar keys mean the response schema changed","Keep the previous positions snapshot (that is the guard's purpose) and surface a 'sync failed, showing last known data' state to the user","Reproduce with curl against /api/v1/accounts/<id>/positions/all using the same auth to see the raw shape","If the schema legitimately changed, update get_positions to map the new envelope before this guard trips for every account"],"exampleFix":"# caller: preserve last good snapshot when the guard trips\nbegin\n  positions = item.get_positions(account_id: id)\n  snapshot.update!(positions: positions)\nrescue Provider::Snaptrade::ApiError => e\n  Rails.logger.warn(\"positions sync failed, keeping snapshot: #{e.message}\")\nend","handlingStrategy":"fallback","validationCode":null,"typeGuard":"def snaptrade_positions_envelope?(response)\n  response.is_a?(Hash) && response[\"results\"].is_a?(Array)\nend","tryCatchPattern":"begin\n  positions = snaptrade.get_positions(account_id: account.id)\n  snapshot.update!(positions: positions)\nrescue Provider::Snaptrade::ApiError => e\n  Rails.logger.warn(\"Keeping last snapshot for account #{account.id}: #{e.message}\")\n  # do not overwrite with [] - the guard fired because the envelope was wrong\nend","preventionTips":["Never translate this error into 'zero positions' - it exists to protect the last good snapshot","Log the keys from the message to detect SnapTrade schema drift early","Add regression VCR cassettes for the positions envelope so renames fail tests first"],"tags":["snaptrade","schema-change","partial-response","positions","type-guard"],"backgroundTag":"api-response-schema-changed","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}