120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
/**
|
|
* Session — State machine for one pool session
|
|
*
|
|
* Lifecycle:
|
|
* active ──(429)──▶ cooldown ──(timeout)──▶ active
|
|
* active ──(5xx)──▶ dead ──(pruned)──▶ removed
|
|
* active ──(TTL)──▶ replaced with fresh session
|
|
*
|
|
* Each session tracks:
|
|
* - Fingerprint (UA + headers for one browser identity)
|
|
* - Request metrics (total, success, fail, inflight)
|
|
* - Cooldown state (exponential backoff on rate limits)
|
|
*/
|
|
|
|
import { type Fingerprint, type SessionStatus } from "./types.ts";
|
|
|
|
export class Session {
|
|
readonly id: string;
|
|
readonly fingerprint: Fingerprint;
|
|
readonly createdAt: number;
|
|
|
|
status: SessionStatus = "active";
|
|
inflight = 0;
|
|
totalRequests = 0;
|
|
successfulRequests = 0;
|
|
failedRequests = 0;
|
|
consecutiveFails = 0;
|
|
cooldownUntil = 0;
|
|
lastUsedAt = 0;
|
|
|
|
constructor(
|
|
fingerprint: Fingerprint,
|
|
private readonly cooldownBase: number,
|
|
private readonly cooldownMax: number,
|
|
private readonly cooldownJitter: number,
|
|
) {
|
|
this.id = `sess-${fingerprint.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
this.fingerprint = fingerprint;
|
|
this.createdAt = Date.now();
|
|
}
|
|
|
|
/** Whether this session can accept requests right now */
|
|
get isAvailable(): boolean {
|
|
if (this.status === "dead") return false;
|
|
if (this.status === "cooldown") {
|
|
if (Date.now() >= this.cooldownUntil) {
|
|
// Auto-recover from cooldown
|
|
this.status = "active";
|
|
this.consecutiveFails = 0;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Mark a successful request */
|
|
markSuccess(): void {
|
|
this.successfulRequests++;
|
|
this.consecutiveFails = 0;
|
|
}
|
|
|
|
/** Enter cooldown with exponential backoff */
|
|
markCooldown(): void {
|
|
this.consecutiveFails++;
|
|
const base = Math.min(
|
|
this.cooldownBase * Math.pow(2, this.consecutiveFails - 1),
|
|
this.cooldownMax,
|
|
);
|
|
const jitter = Math.random() * this.cooldownJitter;
|
|
this.cooldownUntil = Date.now() + base + jitter;
|
|
this.status = "cooldown";
|
|
}
|
|
|
|
/** Mark session as dead (non-recoverable error) */
|
|
markDead(): void {
|
|
this.status = "dead";
|
|
}
|
|
|
|
/** Increment inflight counter and mark as used */
|
|
acquire(): void {
|
|
this.inflight++;
|
|
this.totalRequests++;
|
|
this.lastUsedAt = Date.now();
|
|
}
|
|
|
|
/** Decrement inflight counter */
|
|
release(): void {
|
|
this.inflight = Math.max(0, this.inflight - 1);
|
|
}
|
|
|
|
/** Milliseconds remaining in cooldown */
|
|
get cooldownRemaining(): number {
|
|
if (this.status !== "cooldown") return 0;
|
|
return Math.max(0, this.cooldownUntil - Date.now());
|
|
}
|
|
|
|
/** Age in milliseconds */
|
|
get age(): number {
|
|
return Date.now() - this.createdAt;
|
|
}
|
|
|
|
/** Build headers for this session's fingerprint */
|
|
buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/plain, */*",
|
|
"Accept-Language": this.fingerprint.acceptLanguage ?? "en-US,en;q=0.9",
|
|
"User-Agent": this.fingerprint.userAgent,
|
|
...extra,
|
|
};
|
|
if (this.fingerprint.secChUa) {
|
|
headers["Sec-CH-UA"] = this.fingerprint.secChUa;
|
|
headers["Sec-CH-UA-Mobile"] = this.fingerprint.secChUaMobile ?? "?0";
|
|
headers["Sec-CH-UA-Platform"] = this.fingerprint.secChUaPlatform ?? '"Windows"';
|
|
}
|
|
return headers;
|
|
}
|
|
}
|