{"record":{"id":"5b73dcb02ca40951","repo":"we-promise/sure","slug":"reauth-required","errorCode":"reauth_required","errorMessage":"Questrade token exchange failed (#{response.code}). Re-authorization required.","messagePattern":"Questrade token exchange failed \\(#(.+?)\\)\\. Re-authorization required\\.","errorType":"exception","errorClass":"Provider::Questrade::AuthenticationError","httpStatus":null,"severity":"error","filePath":"app/models/provider/questrade.rb","lineNumber":163,"sourceCode":"          @refresh_token = fresh_token if fresh_token.present?\n          exchange_token!\n        end\n      else\n        exchange_token!\n      end\n    end\n\n    def exchange_token!\n      response = with_retries(\"oauth_token\") do\n        # POST with a form body keeps the single-use refresh token out of the\n        # URL (and therefore out of access logs / error-tracking breadcrumbs).\n        self.class.post(LOGIN_URL, body: { grant_type: \"refresh_token\", refresh_token: @refresh_token })\n      end\n\n      unless response.code == 200\n        # 400/401 here usually means the refresh token expired (>7 days) or was\n        # already used. The connection must be re-authorized by the user.\n        raise AuthenticationError.new(\n          \"Questrade token exchange failed (#{response.code}). Re-authorization required.\",\n          :reauth_required\n        )\n      end\n\n      body = JSON.parse(response.body, symbolize_names: true)\n      @access_token      = body[:access_token]\n      @api_server        = body[:api_server]\n      @refresh_token     = body[:refresh_token] # rotate in-memory immediately\n      @access_expires_at = Time.current + (body[:expires_in].to_i - ACCESS_TOKEN_SKEW).seconds\n\n      # Hand the new credentials to the caller to persist (single-use token!).\n      @on_token_refresh&.call(\n        refresh_token: @refresh_token,\n        api_server:    @api_server,\n        access_token:  @access_token,\n        expires_at:    @access_expires_at\n      )","sourceCodeStart":145,"sourceCodeEnd":181,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/questrade.rb#L145-L181","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before\n# two jobs refresh at once; second exchange uses an already-consumed token\nprovider = Provider::Questrade.new(refresh_token: item.refresh_token)\nprovider.list_accounts # -> reauth_required\n\n# after\nprovider = Provider::Questrade.new(\n  refresh_token: item.refresh_token,\n  on_token_refresh: ->(creds) { item.update_credentials!(creds) },\n  synchronize_exchange: ->(&block) { item.with_lock { block.call } }\n)\nbegin\n  provider.list_accounts\nrescue Provider::Questrade::AuthenticationError => e\n  raise unless e.error_type == :reauth_required\n  item.flag_reauthorization_required!(e) # prompt the user\nend","handlingStrategy":"try-catch","validationCode":"# Questrade refresh tokens die after ~7 days of no exchange; check before syncing\nif item.last_token_exchange_at.present? && item.last_token_exchange_at < 6.days.ago\n  item.flag_reauthorization_required!(reason: \"token expiring\")\n  return\nend","typeGuard":null,"tryCatchPattern":"begin\n  provider.list_accounts\nrescue Provider::Questrade::AuthenticationError => e\n  raise unless e.error_type == :reauth_required\n  item.flag_reauthorization_required!(e) # only the user can fix this\n  SyncJob.disable_for(item)\nend","preventionTips":["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."],"tags":["questrade","oauth","refresh-token","single-use-token","reauthorization","brokerage-api"],"backgroundTag":"refresh-token-expired","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}