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

SnapTrade item has no access token

Error message

SnapTrade item has no access token

What it means

Provider::Snaptrade.new(snaptrade_item) makes API calls via request_json, whose first step is ensure_fresh_token!. It raises AuthenticationError when the item row has no oauth_access_token at all -- i.e. the connection record exists but the OAuth code exchange was never completed or the stored token was cleared. Unlike refresh_access_token!, this raise is not wrapped in the mark_requires_update!/DebugLogEntry rescue, so it propagates straight to the caller.

Source

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

          request.headers["Accept"] = "application/json"
          request.params.update(params) if params.present?
          if body
            request.headers["Content-Type"] = "application/json"
            request.body = body.to_json
          end
        end
      end

      if response.status == 401 && retry_on_auth_failure
        refresh_access_token!(previous_access_token: used_access_token)
        return request_json(method, path, params: params, body: body, retry_on_auth_failure: false)
      end

      handle_response(response, operation)
    end

    def ensure_fresh_token!
      raise AuthenticationError, "SnapTrade item has no access token" if snaptrade_item.oauth_access_token.blank?

      expires_at = snaptrade_item.oauth_token_expires_at
      return if expires_at.blank? || expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now

      refresh_access_token!
    end

    # Guards against a concurrent refresh-token rotation race: multiple threads/processes
    # (e.g. per-account jobs sharing one SnapTrade item) may all observe an expiring/rejected
    # token and attempt to refresh at once. If SnapTrade rotates refresh tokens as single-use,
    # every refresh after the first would fail with invalid_grant and needlessly brick the
    # item. Taking a DB row lock and re-checking freshness after reload ensures only one
    # caller actually performs the HTTP refresh; the rest observe the winner's fresh token.
    #
    # `previous_access_token`, when present, means we're refreshing reactively after a 401 on
    # that specific token (called from request_json). In that case we skip the HTTP refresh
    # only if the DB row's access token has already changed since we made the failed request
    # (i.e. another caller already won the race) -- an expiry-based freshness check would be

View on GitHub (pinned to e69894adb9)

Solutions

  1. Route the user back through the OAuth authorize flow so exchange_code stores a token (item gets apply_oauth_tokens!)
  2. Before syncing, check snaptrade_item.oauth_access_token.present? and skip/flag the item instead of instantiating API calls
  3. Rescue Provider::Snaptrade::AuthenticationError in the sync entry point and mark the item status :requires_update

Example fix

# before
SyncJob.perform_later(item.id)
# SyncJob:
positions = Provider::Snaptrade.new(item).get_positions

# after
# SyncJob:
def perform(item_id)
  item = SnaptradeItem.find(item_id)
  unless item.oauth_access_token.present?
    item.update!(status: :requires_update)
    return
  end
  positions = Provider::Snaptrade.new(item).get_positions
end
Defensive patterns

Strategy: validation

Validate before calling

# Before any API work
return if snaptrade_item.oauth_access_token.blank?

Type guard

def snaptrade_item_authorized?(item)
  item.oauth_access_token.present?
end

Try / catch

begin
  provider.get_positions
rescue Provider::Snaptrade::AuthenticationError => e
  item.update!(status: :requires_update) # prompt re-authorization
end

Prevention

When it happens

Trigger: A sync job or holdings fetch runs against a SnaptradeItem that was created (e.g. placeholder row from an import or an aborted OAuth flow) but never received apply_oauth_tokens! after exchange_code. Also any code path that constructs the provider and calls get_positions/get_connection_url/delete_connection before authorization completed.

Common situations: User abandons the OAuth flow mid-callback (callback errored once, row persisted); test fixtures creating snaptrade_items without tokens; a data migration or manual console edit blanking oauth_access_token.

Related errors


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