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

SnapTrade OAuth token request failed: #{error}

Error message

SnapTrade OAuth token request failed: #{error}

What it means

Raised by Provider::Snaptrade's OAuth token client when the SnapTrade token endpoint answers 4xx during a token request (exchange or refresh). The message combines the static prefix with the endpoint's own error_description or error field (falling back to 'HTTP <status>'). 4xx on a token endpoint is the OAuth server explicitly rejecting the request - bad credentials, bad code, or bad redirect_uri.

Source

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

        # processed it, replaying the same params would fail with invalid_grant
        # even though the original request actually succeeded.
        response = without_retry("POST #{TOKEN_URL}") do
          oauth_connection.post(TOKEN_URL) do |request|
            request.headers["Authorization"] = basic_auth_header
            request.headers["Content-Type"] = "application/x-www-form-urlencoded"
            request.body = URI.encode_www_form(params)
          end
        end

        payload = parse_json(response.body)
        return payload if response.success?

        error = payload["error_description"].presence || payload["error"].presence || "HTTP #{response.status}"
        if (400..499).cover?(response.status)
          raise AuthenticationError, "SnapTrade OAuth token request failed: #{error}"
        end

        raise ApiError.new(
          "SnapTrade OAuth token request failed: #{error}",
          status_code: response.status, response_body: response.body
        )
      end

      def basic_auth_header
        "Basic #{Base64.strict_encode64("#{oauth_client_id}:#{oauth_client_secret}")}"
      end

      def oauth_connection
        Faraday.new do |faraday|
          faraday.options.timeout = 30
          faraday.options.open_timeout = 10
        end
      end

      def parse_json(body)
        body.present? ? JSON.parse(body) : {}

View on GitHub (pinned to e69894adb9)

Solutions

  1. Verify SNAPTRADE_CLIENT_ID and SNAPTRADE_CLIENT_SECRET against the SnapTrade dashboard - invalid_client is the most common cause
  2. Confirm the authorization code is fresh and used exactly once - never retry the token exchange with the same code after any failure
  3. Compare the redirect_uri sent in the token request byte-for-byte with the one used in the authorize step (scheme, host, path, no trailing-slash drift)
  4. Log/inspect the parsed error_description (already extracted into the message) - OAuth servers name the exact invalid parameter

Example fix

# before - code retried on failure (single-use codes get consumed)
result = exchange_code(code) rescue retry

# after - fail fast, never replay a single-use code
result = exchange_code(code) # on AuthenticationError, restart the OAuth flow for a new code
Defensive patterns

Strategy: validation

Validate before calling

raise ArgumentError, "SNAPTRADE_CLIENT_ID/SECRET missing" if ENV["SNAPTRADE_CLIENT_ID"].blank? || ENV["SNAPTRADE_CLIENT_SECRET"].blank?
raise ArgumentError, "code must be present and single-use" if code.blank? || code_used?(code)
mark_code_used!(code) # reserve before the single exchange attempt

Try / catch

begin
  tokens = oauth_client.exchange_code(code)
rescue Provider::Snaptrade::AuthenticationError => e
  restart_authorization_flow # code may be consumed; never replay it
end

Prevention

When it happens

Trigger: POST to the SnapTrade OAuth token endpoint returns 400/401: invalid_client (wrong oauth_client_id/oauth_client_secret), invalid_grant (authorization code expired, already redeemed once, or redirect_uri mismatch), invalid_request (missing parameter in the encoded form body).

Common situations: SNAPTRADE_CLIENT_ID/SECRET env vars wrong or rotated after regenerating keys in the SnapTrade dashboard; the authorization code was consumed by an earlier attempt (codes are single-use) and is being reused on retry; redirect_uri differs by trailing slash or scheme between the authorize request and token exchange; clock skew making a freshly issued code look expired.

Related errors


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