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

Network error (not retried, non-idempotent request): #{e.mes

Error message

Network error (not retried, non-idempotent request): #{e.message}

What it means

Raised by Provider::Snaptrade.without_retry, used for OAuth requests that must not be replayed (single-use authorization codes, token rotation). On a Faraday network error it deliberately does NOT retry, logs 'not retried, non-idempotent', and wraps the failure in an ApiError. The server may or may not have received/processed the request before the connection failed, so the resulting token state is unknown.

Source

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

            sleep(delay)
            retry
          else
            Rails.logger.error(
              "SnapTrade OAuth: #{operation_name} failed after #{max_retries} retries: " \
              "#{e.class}: #{e.message}"
            )
            raise ApiError.new("Network error after #{max_retries} retries: #{e.message}")
          end
        end
      end

      # For requests that must not be replayed (single-use codes, token rotation):
      # translate a network failure into an ApiError without retrying.
      def without_retry(operation_name)
        yield
      rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
        Rails.logger.error("SnapTrade OAuth: #{operation_name} failed (not retried, non-idempotent): #{e.class}: #{e.message}")
        raise ApiError.new("Network error (not retried, non-idempotent request): #{e.message}")
      end

      def calculate_retry_delay(retry_count)
        base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1))
        jitter = base_delay * rand * 0.25
        [ base_delay + jitter, MAX_RETRY_DELAY ].min
      end
  end

  attr_reader :snaptrade_item

  def initialize(snaptrade_item)
    raise ConfigurationError, "snaptrade_item is required" if snaptrade_item.nil?
    @snaptrade_item = snaptrade_item
  end

  # --- Data methods. The SnapTrade user is implicit in the Bearer token. ---

View on GitHub (pinned to e69894adb9)

Solutions

  1. Do NOT replay the same request - the code/old refresh token may already be consumed server-side
  2. For an authorization-code exchange failure, restart the OAuth flow from the beginning to obtain a new code
  3. For refresh-token rotation failure, fall back to the stored previous token; if the server did rotate, catch the resulting auth error and re-authorize
  4. Fix the underlying network instability (see the logged exception class) so the one allowed attempt succeeds

Example fix

# before - retrying a non-idempotent token exchange
begin
  exchange_code(code)
rescue Provider::Snaptrade::ApiError
  retry # WRONG: code may already be consumed
end

# after - fail, then restart the flow for a fresh code
begin
  exchange_code(code)
rescue Provider::Snaptrade::ApiError => e
  redirect_to restart_oauth_flow_path # new authorize code
end
Defensive patterns

Strategy: try-catch

Try / catch

begin
  tokens = oauth_client.exchange_code_without_retry(code)
rescue Provider::Snaptrade::ApiError => e
  # state unknown: code may be consumed. Restart flow instead of replaying.
  invalidate_code(code)
  restart_authorization_flow
end

Prevention

When it happens

Trigger: A connection reset or timeout during the single POST of an authorization-code exchange or refresh-token rotation; a naive retry could double-spend the code or rotate the token twice, so the helper fails immediately instead.

Common situations: Flaky network at exactly the wrong moment during token exchange; retrying this error by hand and hitting the sibling 'invalid_grant' error because the code was already consumed; load balancer dropping idle keepalive connections on the first byte of the response.

Related errors


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