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

reauth_required

reauth_required

Error message

Questrade token exchange failed (#{response.code}). Re-authorization required.

What it means

Raised by Provider::Questrade#exchange_token! when POST https://login.questrade.com/oauth2/token (grant_type=refresh_token) returns a non-200 code. Questrade refresh tokens are single-use and expire 7 days after generation (see the class doc comment); a failed exchange almost always means the token was already consumed by another exchange or is older than 7 days, so only user re-authorization can recover it.

Source

Thrown at app/models/provider/questrade.rb:163

          @refresh_token = fresh_token if fresh_token.present?
          exchange_token!
        end
      else
        exchange_token!
      end
    end

    def exchange_token!
      response = with_retries("oauth_token") do
        # POST with a form body keeps the single-use refresh token out of the
        # URL (and therefore out of access logs / error-tracking breadcrumbs).
        self.class.post(LOGIN_URL, body: { grant_type: "refresh_token", refresh_token: @refresh_token })
      end

      unless response.code == 200
        # 400/401 here usually means the refresh token expired (>7 days) or was
        # already used. The connection must be re-authorized by the user.
        raise AuthenticationError.new(
          "Questrade token exchange failed (#{response.code}). Re-authorization required.",
          :reauth_required
        )
      end

      body = JSON.parse(response.body, symbolize_names: true)
      @access_token      = body[:access_token]
      @api_server        = body[:api_server]
      @refresh_token     = body[:refresh_token] # rotate in-memory immediately
      @access_expires_at = Time.current + (body[:expires_in].to_i - ACCESS_TOKEN_SKEW).seconds

      # Hand the new credentials to the caller to persist (single-use token!).
      @on_token_refresh&.call(
        refresh_token: @refresh_token,
        api_server:    @api_server,
        access_token:  @access_token,
        expires_at:    @access_expires_at
      )

View on GitHub (pinned to e69894adb9)

Solutions

  1. Surface a re-authorization flow to the user — this error is intentionally marked :reauth_required because no retry can fix a dead refresh token.
  2. If concurrency caused it, serialize the exchange: pass the synchronize_exchange callback (the constructor accepts it) so only one exchange runs at a time per item.
  3. Make sure on_token_refresh persists the NEW refresh_token inside the item's row lock immediately, so a crash after exchange cannot lose it.
  4. Confirm only one environment (no staging + prod pair) holds the token; Questrade tokens are single-use across the board.
  5. After the user re-authorizes, verify the fresh token reaches the item store before the next sync fires.

Example fix

# before
# two jobs refresh at once; second exchange uses an already-consumed token
provider = Provider::Questrade.new(refresh_token: item.refresh_token)
provider.list_accounts # -> reauth_required

# after
provider = Provider::Questrade.new(
  refresh_token: item.refresh_token,
  on_token_refresh: ->(creds) { item.update_credentials!(creds) },
  synchronize_exchange: ->(&block) { item.with_lock { block.call } }
)
begin
  provider.list_accounts
rescue Provider::Questrade::AuthenticationError => e
  raise unless e.error_type == :reauth_required
  item.flag_reauthorization_required!(e) # prompt the user
end
Defensive patterns

Strategy: try-catch

Validate before calling

# Questrade refresh tokens die after ~7 days of no exchange; check before syncing
if item.last_token_exchange_at.present? && item.last_token_exchange_at < 6.days.ago
  item.flag_reauthorization_required!(reason: "token expiring")
  return
end

Try / catch

begin
  provider.list_accounts
rescue Provider::Questrade::AuthenticationError => e
  raise unless e.error_type == :reauth_required
  item.flag_reauthorization_required!(e) # only the user can fix this
  SyncJob.disable_for(item)
end

Prevention

When it happens

Trigger: Exchanging a refresh token that was already used once (single-use rotation) — e.g. two syncs exchanging concurrently, or a process crash after exchange but before on_token_refresh persisted the new token; exchanging a token older than 7 days because the item has not synced in over a week; a revoked Questrade authorization returning 400/401.

Common situations: Concurrent jobs both calling ensure_authenticated! without the synchronize_exchange lock; redeploy mid-exchange losing the just-rotated token; user paused syncs for vacation (>7 days) and the stale token is exchanged on resume; local and production both configured with the same seed token.

Related errors


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