{"record":{"id":"b5d27d904be22627","repo":"we-promise/sure","slug":"snaptrade-item-has-no-access-token","errorCode":null,"errorMessage":"SnapTrade item has no access token","messagePattern":"SnapTrade item has no access token","errorType":"exception","errorClass":"Provider::Snaptrade::AuthenticationError","httpStatus":null,"severity":"error","filePath":"app/models/provider/snaptrade.rb","lineNumber":314,"sourceCode":"          request.headers[\"Accept\"] = \"application/json\"\n          request.params.update(params) if params.present?\n          if body\n            request.headers[\"Content-Type\"] = \"application/json\"\n            request.body = body.to_json\n          end\n        end\n      end\n\n      if response.status == 401 && retry_on_auth_failure\n        refresh_access_token!(previous_access_token: used_access_token)\n        return request_json(method, path, params: params, body: body, retry_on_auth_failure: false)\n      end\n\n      handle_response(response, operation)\n    end\n\n    def ensure_fresh_token!\n      raise AuthenticationError, \"SnapTrade item has no access token\" if snaptrade_item.oauth_access_token.blank?\n\n      expires_at = snaptrade_item.oauth_token_expires_at\n      return if expires_at.blank? || expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now\n\n      refresh_access_token!\n    end\n\n    # Guards against a concurrent refresh-token rotation race: multiple threads/processes\n    # (e.g. per-account jobs sharing one SnapTrade item) may all observe an expiring/rejected\n    # token and attempt to refresh at once. If SnapTrade rotates refresh tokens as single-use,\n    # every refresh after the first would fail with invalid_grant and needlessly brick the\n    # item. Taking a DB row lock and re-checking freshness after reload ensures only one\n    # caller actually performs the HTTP refresh; the rest observe the winner's fresh token.\n    #\n    # `previous_access_token`, when present, means we're refreshing reactively after a 401 on\n    # 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","sourceCodeStart":296,"sourceCodeEnd":332,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/snaptrade.rb#L296-L332","documentation":"Provider::Snaptrade.new(snaptrade_item) makes API calls via request_json, whose first step is ensure_fresh_token!. It raises AuthenticationError when the item row has no oauth_access_token at all -- i.e. the connection record exists but the OAuth code exchange was never completed or the stored token was cleared. Unlike refresh_access_token!, this raise is not wrapped in the mark_requires_update!/DebugLogEntry rescue, so it propagates straight to the caller.","triggerScenarios":"A sync job or holdings fetch runs against a SnaptradeItem that was created (e.g. placeholder row from an import or an aborted OAuth flow) but never received apply_oauth_tokens! after exchange_code. Also any code path that constructs the provider and calls get_positions/get_connection_url/delete_connection before authorization completed.","commonSituations":"User abandons the OAuth flow mid-callback (callback errored once, row persisted); test fixtures creating snaptrade_items without tokens; a data migration or manual console edit blanking oauth_access_token.","solutions":["Route the user back through the OAuth authorize flow so exchange_code stores a token (item gets apply_oauth_tokens!)","Before syncing, check snaptrade_item.oauth_access_token.present? and skip/flag the item instead of instantiating API calls","Rescue Provider::Snaptrade::AuthenticationError in the sync entry point and mark the item status :requires_update"],"exampleFix":"# before\nSyncJob.perform_later(item.id)\n# SyncJob:\npositions = Provider::Snaptrade.new(item).get_positions\n\n# after\n# SyncJob:\ndef perform(item_id)\n  item = SnaptradeItem.find(item_id)\n  unless item.oauth_access_token.present?\n    item.update!(status: :requires_update)\n    return\n  end\n  positions = Provider::Snaptrade.new(item).get_positions\nend","handlingStrategy":"validation","validationCode":"# Before any API work\nreturn if snaptrade_item.oauth_access_token.blank?","typeGuard":"def snaptrade_item_authorized?(item)\n  item.oauth_access_token.present?\nend","tryCatchPattern":"begin\n  provider.get_positions\nrescue Provider::Snaptrade::AuthenticationError => e\n  item.update!(status: :requires_update) # prompt re-authorization\nend","preventionTips":["Only create the SnaptradeItem row after exchange_code succeeded and tokens were persisted","Filter sync jobs on oauth_access_token.present? at enqueue time"],"tags":["snaptrade","oauth","access-token","authentication","sync"],"backgroundTag":"missing-access-token","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}