fix(pl24): kendi bütçe frenimiz "hesap banlandı" alarmı üretmesin
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run

Prod 2026-09-21 19:10 (İstanbul) itibarıyla Telegram "🔴 PL24 giriş
yapılamıyor — hesap banlanmış olabilir" dedi. Hesap sağlamdı:
kullanıcı tarayıcıdan giriş yapabildi, aynı gün 143 başarılı auth
çağrısı ve **0 × 401** vardı. Gerçek sebep alarmın kendi metninde:
"PL24 daily HTTP budget exhausted (user lane: 1205/1200)" — yani
upstream değil, BİZİM günlük tavanımız.

Zincir: `attemptLogin` içindeki `budget.consume()` fırlatıyor → dış
`catch` bunu `{ error }` olarak düzleştiriyor → `loginWithLock`
`!result.sessionToken` dalına giriyor → `breakerFail()` + kesinti
sayacı → bir saat sonra ban alarmı. Yani kendi frenimiz hem devre
kesiciyi tetikliyor hem de operatörü yanlış yere çağırıyor.

- `PL24BudgetExceededError` artık `attemptLogin` ve
  `authorizeServiceForAccount` içinde olduğu gibi yukarı çıkıyor;
  giriş/yetkilendirme hatasına çevrilmiyor. Devre kesici tetiklenmiyor,
  kesinti sayacı başlamıyor, Telegram susuyor. Çağıran taraf PL24'ü
  atlayıp pcat/emex'e düşüyor — bugün de öyle oluyordu, ama artık
  sessizce ve doğru gerekçeyle.
- Şerit bazlı sayaç eklendi: `pl24:http:<gün>:{user,worker}`. Paylaşılan
  toplam tavanın NE ZAMAN dolduğunu söylüyordu ama KİMİN harcadığını
  söylemiyordu; tavanı veriyle boyutlandırmak için bu şart. Bugün
  toplam 1200'e vurdu ve 16:10 UTC'den sonra her kullanıcı decode'u
  reddedildi, ama ne kadarının ısıtma prefetch'i ne kadarının ekran
  başında bekleyen biri olduğu `proxy_logs`'tan çıkarılamıyordu.

5 yeni test. api 650 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 20:52:52 +03:00
parent e03d721c37
commit 41147f3f05
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
}