fix(pl24): kendi bütçe frenimiz "hesap banlandı" alarmı üretmesin #271

Open
root wants to merge 1 commits from dev into main
4 changed files with 77 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24BudgetExceededError } from "./pl24-budget.service";
// Oturum modeli (1 login → PL24TOKEN çerezi → yalnız authorize ile yenileme),
// PL24_TR_DISABLED köprüsü ve login devre kesici. Ağ yok: global fetch stub'ı;
@@ -459,3 +460,33 @@ describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => {
expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false);
});
});
/**
* Kendi günlük tavanımız dolduğunda bu bir GİRİŞ HATASI değildir.
*
* Prod 2026-09-21: tavan dolunca `attemptLogin` içindeki `budget.consume()`
* fırlattı, dış `catch` bunu `{ error }` düzleştirdi, `loginWithLock` giriş
* hatası sanıp devre kesiciyi tetikledi ve kesinti sayacını başlattı — bir saat
* sonra Telegram "hesap banlanmış olabilir" dedi. Hesap sağlamdı (tarayıcıdan
* giriş çalışıyordu, aynı gün 143 başarılı auth ve 0 × 401).
*/
describe("bütçe reddi ban alarmı üretmemeli", () => {
it("bütçe hatası olduğu gibi yukarı çıkar, giriş hatasına çevrilmez", async () => {
const { svc, redis, telegram, budget } = makeService();
budget.consume = async () => {
throw new PL24BudgetExceededError("user", 1205, 1200);
};
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
await expect((svc as never as Exposed).login("de")).rejects.toBeInstanceOf(
PL24BudgetExceededError,
);
// Kesinti sayacı başlamamalı → Telegram susmalı.
expect(telegram.sent).toHaveLength(0);
const failKeys = Object.keys(redis.store ?? {}).filter((k) => k.includes("fail-since"));
expect(failKeys).toHaveLength(0);
vi.unstubAllGlobals();
});
});

View File

@@ -26,7 +26,7 @@ import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@n
import { ConfigService } from "@nestjs/config";
import { TelegramService } from "../../common/telegram.service";
import { RedisService } from "../../redis/redis.service";
import { PL24BudgetService } from "./pl24-budget.service";
import { PL24BudgetExceededError, PL24BudgetService } from "./pl24-budget.service";
import { PL24_DEFAULTS, PL24_ENDPOINTS, PL24_USER_AGENT } from "./pl24.constants";
import {
PL24AuthorizeRequest,
@@ -485,6 +485,15 @@ export class PL24AuthService implements OnModuleInit {
return { sessionToken };
} catch (error) {
const err = error as Error;
// OUR OWN daily cap refusing to spend is not a login failure. Flattening it
// into `{ error }` made `loginWithLock` treat it as one: it tripped the
// login circuit breaker and started the outage timer, so an hour later the
// Telegram alert claimed the account might be banned — while the account
// was fine (browser login worked, telemetry showed 0 × 401 and 143
// successful auth calls that same day). Observed on prod 2026-09-21:
// "PL24 daily HTTP budget exhausted (user lane: 1205/1200)". Let it through
// as itself so the caller can skip PL24 without anyone being paged.
if (error instanceof PL24BudgetExceededError) throw error;
if (err.name === "TimeoutError") return { error: "giris zaman asimina ugradi" };
return { error: err.message };
}
@@ -524,6 +533,9 @@ export class PL24AuthService implements OnModuleInit {
return token;
} catch (error) {
const err = error as Error;
// Same reasoning as attemptLogin: a self-imposed budget stop is not an
// authorization problem and must not be reported as one.
if (error instanceof PL24BudgetExceededError) throw error;
this.logger.error(`Service authorization error (${account}): ${err.message}`);
throw err instanceof UnauthorizedException
? err

View File

@@ -139,3 +139,28 @@ describe("PL24BudgetService — telemetri", () => {
expect(telemetry.events[0].statusCode).toBeNull();
});
});
/**
* Bütçe reddi bir giriş hatası DEĞİLDİR (plv2.md, bulgu auth-16).
*
* Prod 2026-09-21: günlük tavan dolunca `attemptLogin` içindeki
* `budget.consume()` fırlattı, dış `catch` bunu `{ error }` düzleştirdi,
* `loginWithLock` bunu giriş hatası sanıp devre kesiciyi tetikledi ve kesinti
* sayacını başlattı → bir saat sonra Telegram "hesap banlanmış olabilir" dedi.
* Oysa hesap sağlamdı: tarayıcıdan giriş çalışıyordu, aynı gün 143 başarılı
* auth çağrısı ve 0 × 401 vardı.
*/
describe("PL24BudgetExceededError — kendi frenimiz, upstream hatası değil", () => {
it("kendi hata sınıfını taşır, düz Error değil", () => {
const err = new PL24BudgetExceededError("user", 1205, 1200);
expect(err).toBeInstanceOf(PL24BudgetExceededError);
expect(err).toBeInstanceOf(Error);
});
it("mesajı hangi şeridin ve hangi sayının durduğunu söyler", () => {
const err = new PL24BudgetExceededError("user", 1205, 1200);
expect(err.message).toContain("1205");
expect(err.message).toContain("1200");
expect(err.message.toLowerCase()).toContain("user");
});
});

View File

@@ -64,6 +64,14 @@ export class PL24BudgetService {
const key = this.dayKey();
spent = await this.redis.incr(key);
if (spent === 1) await this.redis.expire(key, 8 * 86_400);
// Per-lane counters. The shared total tells us WHEN the cap was hit but not
// WHO spent it, and sizing the cap needs that split: on 2026-09-21 the
// total hit 1200 and every user decode after 16:10 UTC was refused, with
// no way to tell from `proxy_logs` how much of it was warm-up prefetch
// versus somebody waiting on a screen.
const laneKey = `${key}:${backfill ? "worker" : "user"}`;
const laneSpent = await this.redis.incr(laneKey);
if (laneSpent === 1) await this.redis.expire(laneKey, 8 * 86_400);
} catch {
return; // Redis down → never block PL24 on telemetry
}