{"record":{"id":"5aa3a8923c50cee5","repo":"we-promise/sure","slug":"unauthorized-5aa3a8","errorCode":"unauthorized","errorMessage":"Invalid or expired Questrade credentials","messagePattern":"Invalid or expired Questrade credentials","errorType":"exception","errorClass":"Provider::Questrade::AuthenticationError","httpStatus":401,"severity":"error","filePath":"app/models/provider/questrade.rb","lineNumber":254,"sourceCode":"      DebugLogEntry.capture(\n        category: \"provider_sync\",\n        level: \"error\",\n        message: \"Questrade API #{reason} (#{response.code})\",\n        source: self.class.name,\n        provider_key: \"questrade\",\n        metadata: { status: response.code, body: response.body.to_s.first(1000) }\n      )\n    end\n\n    def handle_response(response)\n      case response.code\n      when 200, 201\n        JSON.parse(response.body, symbolize_names: true)\n      when 400\n        capture_response_error(\"bad_request\", response)\n        raise Error.new(\"Questrade bad request (#{response.code})\", :bad_request)\n      when 401\n        raise AuthenticationError.new(\"Invalid or expired Questrade credentials\", :unauthorized)\n      when 403\n        raise AuthenticationError.new(\"Access forbidden - check your permissions\", :access_forbidden)\n      when 404\n        raise Error.new(\"Resource not found\", :not_found)\n      when 429\n        raise RetryableResponseError.new(\"Questrade rate limit exceeded. Please try again later.\", :rate_limited)\n      when 500..599\n        raise RetryableResponseError.new(\"Questrade server error (#{response.code}). Please try again later.\", :server_error)\n      else\n        capture_response_error(\"unexpected_response\", response)\n        raise Error.new(\"Questrade unexpected response (#{response.code})\", :unknown)\n      end\n    end\nend\n","sourceCodeStart":236,"sourceCodeEnd":269,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/questrade.rb#L236-L269","documentation":"Raised by Provider::Questrade#handle_response on HTTP 401 from a data call (against api_server). Note get_json already handles the common case: on 401 it force-refreshes the token and replays the request once. So this error means the retried request still came back 401 — the access token is genuinely invalid (revoked authorization, wrong api_server/token pairing) rather than merely expired mid-sync.","triggerScenarios":"User revoked the app's authorization in Questrade, invalidating issued tokens; api_server from a different session paired with a fresh token (Questrade tokens are bound to the api_server returned with them); a forced refresh that raced another process's exchange, leaving an already-consumed token; clock skew beyond ACCESS_TOKEN_SKEW=60s combined with a refresh that fails silently.","commonSituations":"User disconnects the app in Questrade's security settings while a sync runs; item row restored from an old backup mixing an old api_server with a newer token; two workers exchanging concurrently despite the serialize-exchange design.","solutions":["Treat as re-auth territory: if the user revoked access, only re-authorization fixes it — flag the item for the user to reconnect.","Verify api_server and access token came from the SAME exchange response (persist them together in on_token_refresh) — mixing sessions yields persistent 401s.","Ensure the exchange is serialized per item (synchronize_exchange) so a racing refresh cannot consume the token under you.","Check for NTP/clock drift on the host if 401s appear only near the 30-minute access-token boundary.","Reproduce with curl using the access token against the stored api_server to confirm whether Questrade still honors it."],"exampleFix":"# before\nbalances = provider.get_balances(account_id: id)\n\n# after\nbegin\n  balances = provider.get_balances(account_id: id)\nrescue Provider::Questrade::AuthenticationError => e\n  raise unless e.error_type == :unauthorized\n  # get_json already retried after a forced refresh; still 401 -> dead session\n  item.flag_reauthorization_required!(e)\n  raise\nend","handlingStrategy":"try-catch","validationCode":"# ensure token and api_server come from the SAME exchange before data calls\nif item.settings[\"api_server\"].blank? || item.settings[\"access_token\"].blank?\n  provider.exchange_token!\nend","typeGuard":null,"tryCatchPattern":"begin\n  provider.get_balances(account_id: id)\nrescue Provider::Questrade::AuthenticationError => e\n  raise unless e.error_type == :unauthorized\n  # get_json already force-refreshed once; still 401 means revoked or mixed session\n  item.flag_reauthorization_required!(e)\nend","preventionTips":["Persist access_token, refresh_token and api_server atomically from one exchange response.","Serialize exchanges per item so a concurrent refresh cannot invalidate the token in flight.","When users disconnect the app in Questrade, expect this error and route to re-auth, not retry."],"tags":["questrade","http-401","access-token","revoked-authorization","brokerage-api"],"backgroundTag":"access-token-expired","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}