we-promise/sure · error · Provider::Snaptrade::ApiError
SnapTrade positions response has no results array
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.
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
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
- 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
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
- invalid_response
- Authentication failed
- Cannot rollback: cross-item duplicates exist in…
- Network error after #
- Network error (not retried, non-idempotent request): #
AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21).
Data as JSON: /api/errors/1705e77f93de5c4c.
Report an issue: GitHub.
Appendix: 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)