{"record":{"id":"b21422e3ec3d5084","repo":"we-promise/sure","slug":"snaptrade-item-has-no-refresh-token","errorCode":null,"errorMessage":"SnapTrade item has no refresh token","messagePattern":"SnapTrade item has no refresh token","errorType":"exception","errorClass":"Provider::Snaptrade::AuthenticationError","httpStatus":null,"severity":"error","filePath":"app/models/provider/snaptrade.rb","lineNumber":348,"sourceCode":"    # that specific token (called from request_json). In that case we skip the HTTP refresh\n    # only if the DB row's access token has already changed since we made the failed request\n    # (i.e. another caller already won the race) -- an expiry-based freshness check would be\n    # wrong here since the server rejected a token we believed was still time-valid.\n    # When `previous_access_token` is absent, we're refreshing proactively (from\n    # ensure_fresh_token!) and skip only if the reloaded row is still time-fresh.\n    def refresh_access_token!(previous_access_token: nil)\n      snaptrade_item.with_lock do\n        snaptrade_item.reload\n\n        if previous_access_token.present?\n          next if snaptrade_item.oauth_access_token != previous_access_token\n        else\n          expires_at = snaptrade_item.oauth_token_expires_at\n          next if expires_at.present? && expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now\n        end\n\n        refresh_token = snaptrade_item.oauth_refresh_token\n        raise AuthenticationError, \"SnapTrade item has no refresh token\" if refresh_token.blank?\n\n        payload = self.class.refresh_tokens(refresh_token: refresh_token)\n        snaptrade_item.apply_oauth_tokens!(payload)\n      end\n    rescue AuthenticationError => e\n      mark_requires_update!\n      DebugLogEntry.capture(\n        category: \"provider_sync\",\n        level: :error,\n        message: \"SnapTrade token refresh failed: #{e.message}\",\n        source: \"Provider::Snaptrade\",\n        provider_key: \"snaptrade\",\n        family: snaptrade_item.try(:family),\n        metadata: { snaptrade_item_id: snaptrade_item.try(:id) }\n      )\n      raise\n    end\n","sourceCodeStart":330,"sourceCodeEnd":366,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/snaptrade.rb#L330-L366","documentation":"Inside refresh_access_token! (called under a DB row lock from ensure_fresh_token! when the access token is within TOKEN_EXPIRY_LEEWAY of expiry, or reactively after a 401), the item must have an oauth_refresh_token to mint a new access token. If the column is blank, AuthenticationError is raised; the method's rescue then marks the item status :requires_update, writes a DebugLogEntry (category provider_sync, provider snaptrade), and re-raises, so the sync aborts but the failure is visible in /settings/debug.","triggerScenarios":"Item has an access token but the stored refresh token is nil -- e.g. the original token payload from SnapTrade contained no refresh_token, the column was cleared, or a previous partial apply_oauth_tokens! persisted only the access token. It fires on the first sync after the access token enters the 60-second leeway window or gets a 401.","commonSituations":"SnapTrade issued no offline refresh token for the authorization (scope/grant configuration); encrypted-attribute migration or restore losing the refresh column; concurrent flows where an older row version is written back; items connected against a pre-release API that changed its token payload shape.","solutions":["Have the user re-authorize the item (new authorize_url flow) so a complete token set is stored","Check the stored payload: in console inspect the item's oauth_refresh_token / the original exchange response for a refresh_token field before re-auth","If re-auth also yields no refresh token, verify the OAuth app's allowed scopes/grants on dashboard.snaptrade.com"],"exampleFix":"# before\ndef perform(item_id)\n  Provider::Snaptrade.new(SnaptradeItem.find(item_id)).get_positions\nend\n\n# after\ndef perform(item_id)\n  item = SnaptradeItem.find(item_id)\n  if item.oauth_token_expires_at.present? && item.oauth_refresh_token.blank?\n    item.update!(status: :requires_update)\n    return\n  end\n  Provider::Snaptrade.new(item).get_positions\nrescue Provider::Snaptrade::AuthenticationError\n  retry if item.reload.status_previously_changed?\nend","handlingStrategy":"try-catch","validationCode":"# Skip proactive refresh when there is nothing to refresh with\nif item.oauth_token_expires_at.present? && item.oauth_refresh_token.blank?\n  item.update!(status: :requires_update)\nend","typeGuard":"def snaptrade_item_refreshable?(item)\n  item.oauth_access_token.present? && item.oauth_refresh_token.present?\nend","tryCatchPattern":"begin\n  provider.get_positions\nrescue Provider::Snaptrade::AuthenticationError\n  item.reload\n  raise unless item.status_requires_update? # already flagged by the provider; surface re-auth to user\nend","preventionTips":["After exchange_code, assert the payload contained refresh_token before persisting the item as fully connected","Monitor items whose expiry is near but refresh token is blank and proactively ask users to reconnect"],"tags":["snaptrade","oauth","refresh-token","authentication","requires-update"],"backgroundTag":"missing-refresh-token","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}