we-promise/sure · error · Provider::Snaptrade::ApiError

SnapTrade positions response has no results array (keys: #{r

Error message

SnapTrade positions response has no results array (keys: #{response.is_a?(Hash) ? response.keys.inspect : response.class})

What it means

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.

Source

Thrown at app/models/provider/snaptrade.rb:224

  def list_accounts
    get_json("/api/v1/accounts")
  end

  # Returns Array<Hash> of balance entries
  def get_balances(account_id:)
    get_json("/api/v1/accounts/#{account_id}/balances")
  end

  # Returns Array<Hash> of positions
  def get_positions(account_id:)
    response = get_json("/api/v1/accounts/#{account_id}/positions/all")
    results = response["results"] if response.is_a?(Hash)

    # An empty `results` is a legitimately empty account, but a missing one is
    # a partial or schema-changed response. Raising leaves the previous
    # snapshot in place rather than overwriting it with nothing.
    unless results.is_a?(Array)
      raise ApiError.new(
        "SnapTrade positions response has no results array " \
        "(keys: #{response.is_a?(Hash) ? response.keys.inspect : response.class})"
      )
    end

    results.reject { |position| unsupported_instrument?(position) }
  end

  # Returns raw JSON: paginated form is {"data" => [...]}, may also be a plain Array
  def get_account_activities(account_id:, start_date: nil, end_date: nil)
    params = {}
    params[:startDate] = start_date.to_date.to_s if start_date
    params[:endDate] = end_date.to_date.to_s if end_date
    get_json("/api/v1/accounts/#{account_id}/activities", params)
  end

  # Cross-account activities endpoint. Returns Array<Hash>.
  def get_activities(start_date: nil, end_date: nil, accounts: nil, brokerage_authorizations: nil, type: nil)

View on GitHub (pinned to e69894adb9)

Solutions

  1. Read the keys in the message - an 'error' key means a masked upstream failure; unfamiliar keys mean the response schema changed
  2. Keep the previous positions snapshot (that is the guard's purpose) and surface a 'sync failed, showing last known data' state to the user
  3. Reproduce with curl against /api/v1/accounts/<id>/positions/all using the same auth to see the raw shape
  4. If the schema legitimately changed, update get_positions to map the new envelope before this guard trips for every account

Example fix

# caller: preserve last good snapshot when the guard trips
begin
  positions = item.get_positions(account_id: id)
  snapshot.update!(positions: positions)
rescue Provider::Snaptrade::ApiError => e
  Rails.logger.warn("positions sync failed, keeping snapshot: #{e.message}")
end
Defensive patterns

Strategy: fallback

Type guard

def snaptrade_positions_envelope?(response)
  response.is_a?(Hash) && response["results"].is_a?(Array)
end

Try / catch

begin
  positions = snaptrade.get_positions(account_id: account.id)
  snapshot.update!(positions: positions)
rescue Provider::Snaptrade::ApiError => e
  Rails.logger.warn("Keeping last snapshot for account #{account.id}: #{e.message}")
  # do not overwrite with [] - the guard fired because the envelope was wrong
end

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/1705e77f93de5c4c. Report an issue: GitHub.