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
- Surface a re-authorization flow to the user — this error is intentionally marked :reauth_required because no retry can fix a dead refresh token.
- 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.
- 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.
- Confirm only one environment (no staging + prod pair) holds the token; Questrade tokens are single-use across the board.
- 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
- Serialize the token exchange per item (synchronize_exchange) so two jobs can never consume the single-use token.
- Persist the rotated refresh_token inside on_token_refresh under the item's row lock immediately after exchange.
- Run a sync at least weekly so the rotating token never crosses its 7-day expiry.
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
- missing_credentials
- This property data provider is not configured.
- missing_api_server
- network_error
- bad_request
AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21).
Data as JSON: /api/errors/5b73dcb02ca40951.
Report an issue: GitHub.