From 8c2fb28777e1f4e3be43fd37f9e4a6a791989d44 Mon Sep 17 00:00:00 2001 From: Xevalous Date: Fri, 27 Mar 2026 22:08:02 +0700 Subject: [PATCH] feat: handle "too many requests" rate limit during login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect rate limit on login.live.com/ppsecure/post.srf and retry with configurable delay - Add loginRateLimit config (delay, maxAttempts) with defaults, backward compatible - Global IP-based cooldown via shared file — all accounts/workers coordinate, no duplicate retries - Skip account only when retries exhausted, otherwise wait and retry --- src/browser/auth/Login.ts | 61 ++++++++++++++++++++++++++++++++++++++- src/config.example.json | 4 +++ src/interface/Config.ts | 6 ++++ src/util/Load.ts | 41 ++++++++++++++++++++++++++ src/util/Validator.ts | 7 +++++ 5 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index ba946bad..e99f3ef9 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -1,6 +1,6 @@ import type { Page } from 'patchright' import type { MicrosoftRewardsBot } from '../../index' -import { saveSessionData } from '../../util/Load' +import { saveSessionData, setRateLimitCooldown, getRateLimitCooldown } from '../../util/Load' import { MobileAccessLogin } from './methods/MobileAccessLogin' import { EmailLogin } from './methods/EmailLogin' @@ -30,6 +30,7 @@ type LoginState = | 'OTP_CODE_ENTRY' | 'UNKNOWN' | 'CHROMEWEBDATA_ERROR' + | 'TOO_MANY_REQUESTS' export class Login { emailLogin: EmailLogin @@ -37,6 +38,7 @@ export class Login { totp2FALogin: TotpLogin codeLogin: CodeLogin recoveryLogin: RecoveryLogin + private loginRetryCount = 0 private readonly selectors = { primaryButton: 'button[data-testid="primaryButton"]', @@ -76,8 +78,26 @@ export class Login { async login(page: Page, account: Account) { try { + this.loginRetryCount = 0 this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process') + // Check global IP-based rate limit cooldown + const remainingCooldown = getRateLimitCooldown(this.bot.config.sessionPath) + if (remainingCooldown > 0) { + const { maxAttempts } = this.bot.config.loginRateLimit! + if (this.loginRetryCount >= maxAttempts) { + const msg = 'IP is rate limited and max retries exhausted, skipping account' + this.bot.logger.error(this.bot.isMobile, 'LOGIN', msg) + throw new Error(msg) + } + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + `IP rate limit active, waiting remaining ${Math.ceil(remainingCooldown / 1000)}s` + ) + await this.bot.utils.wait(remainingCooldown) + } + await page .goto('https://rewards.bing.com/createuser?idru=%2F&userScenarioId=anonsignin', { waitUntil: 'domcontentloaded' @@ -168,6 +188,15 @@ export class Login { return 'CHROMEWEBDATA_ERROR' } + // Check for "too many requests" rate limiting on post-srf page + if (url.hostname === 'login.live.com' && url.pathname === '/ppsecure/post.srf') { + const pageContent = await page.content().catch(() => '') + if (pageContent.toLowerCase().includes('too many requests')) { + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Detected "too many requests" rate limit page') + return 'TOO_MANY_REQUESTS' + } + } + const isLocked = await this.checkSelector(page, this.selectors.accountLocked) if (isLocked) { this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Account locked selector found') @@ -457,6 +486,36 @@ export class Login { } } + case 'TOO_MANY_REQUESTS': { + const { delay, maxAttempts } = this.bot.config.loginRateLimit! + this.loginRetryCount++ + + if (this.loginRetryCount > maxAttempts) { + const msg = `Rate limit retry exhausted after ${maxAttempts} attempts, skipping account` + this.bot.logger.error(this.bot.isMobile, 'LOGIN', msg) + throw new Error(msg) + } + + const delayMs = this.bot.utils.stringToNumber(delay) + + // Set global cooldown so other accounts/workers are aware + setRateLimitCooldown(this.bot.config.sessionPath, delayMs) + this.bot.logger.warn( + this.bot.isMobile, + 'LOGIN', + `Too many requests detected, retrying in ${delay} (${this.loginRetryCount}/${maxAttempts})` + ) + await this.bot.utils.wait(delayMs) + await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {}) + await this.bot.utils.wait(2000) + this.bot.logger.info( + this.bot.isMobile, + 'LOGIN', + `Retry ${this.loginRetryCount}/${maxAttempts} after rate limit` + ) + return true + } + case '2FA_TOTP': { this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA authentication required') await this.totp2FALogin.handle(page, account.totpSecret) diff --git a/src/config.example.json b/src/config.example.json index e97eb962..fb301b29 100644 --- a/src/config.example.json +++ b/src/config.example.json @@ -17,6 +17,10 @@ }, "searchOnBingLocalQueries": false, "globalTimeout": "30sec", + "loginRateLimit": { + "delay": "5min", + "maxAttempts": 3 + }, "searchSettings": { "scrollRandomResults": false, "clickRandomResults": false, diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 6cb63388..cbe85347 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -7,6 +7,7 @@ export interface Config { workers: ConfigWorkers searchOnBingLocalQueries: boolean globalTimeout: number | string + loginRateLimit?: ConfigLoginRateLimit searchSettings: ConfigSearchSettings debugLogs: boolean proxy: ConfigProxy @@ -14,6 +15,11 @@ export interface Config { webhook: ConfigWebhook } +export interface ConfigLoginRateLimit { + delay: number | string + maxAttempts: number +} + export type QueryEngine = 'google' | 'wikipedia' | 'reddit' | 'local' export interface ConfigSearchSettings { diff --git a/src/util/Load.ts b/src/util/Load.ts index 3e75f996..46640c68 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -127,3 +127,44 @@ export async function saveFingerprintData( throw new Error(error as string) } } + +const RATE_LIMIT_FILE = '.rate-limit-cooldown' + +function getRateLimitFilePath(sessionPath: string): string { + return path.join(__dirname, '../browser/', sessionPath, RATE_LIMIT_FILE) +} + +export function setRateLimitCooldown(sessionPath: string, delayMs: number): void { + const filePath = getRateLimitFilePath(sessionPath) + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + const expiry = Date.now() + delayMs + fs.writeFileSync(filePath, String(expiry)) +} + +export function getRateLimitCooldown(sessionPath: string): number { + const filePath = getRateLimitFilePath(sessionPath) + if (!fs.existsSync(filePath)) { + return 0 + } + const expiry = parseInt(fs.readFileSync(filePath, 'utf-8').trim(), 10) + if (isNaN(expiry)) { + fs.unlinkSync(filePath) + return 0 + } + const remaining = expiry - Date.now() + if (remaining <= 0) { + fs.unlinkSync(filePath) + return 0 + } + return remaining +} + +export function clearRateLimitCooldown(sessionPath: string): void { + const filePath = getRateLimitFilePath(sessionPath) + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } +} diff --git a/src/util/Validator.ts b/src/util/Validator.ts index 333dda4d..e0758c45 100644 --- a/src/util/Validator.ts +++ b/src/util/Validator.ts @@ -64,6 +64,13 @@ export const ConfigSchema = z.object({ }), searchOnBingLocalQueries: z.boolean(), globalTimeout: NumberOrString, + loginRateLimit: z + .object({ + delay: NumberOrString, + maxAttempts: z.number().int().positive().max(100) + }) + .optional() + .default({ delay: '5min', maxAttempts: 3 }), searchSettings: z.object({ scrollRandomResults: z.boolean(), clickRandomResults: z.boolean(),