From 41147f3f05c2121e6e683863ba766bb8a4ffcd52 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Mon, 21 Sep 2026 20:52:52 +0300 Subject: [PATCH] =?UTF-8?q?fix(pl24):=20kendi=20b=C3=BCt=C3=A7e=20frenimiz?= =?UTF-8?q?=20"hesap=20banland=C4=B1"=20alarm=C4=B1=20=C3=BCretmesin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::{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) --- .../pl24/pl24-auth.service.spec.ts | 31 +++++++++++++++++++ .../integrations/pl24/pl24-auth.service.ts | 14 ++++++++- .../pl24/pl24-budget.service.spec.ts | 25 +++++++++++++++ .../integrations/pl24/pl24-budget.service.ts | 8 +++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts b/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts index 8b84d5e..60e4f94 100644 --- a/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts +++ b/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24-auth.service.ts b/apps/api/src/integrations/pl24/pl24-auth.service.ts index 2c610cb..92aadaa 100644 --- a/apps/api/src/integrations/pl24/pl24-auth.service.ts +++ b/apps/api/src/integrations/pl24/pl24-auth.service.ts @@ -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 diff --git a/apps/api/src/integrations/pl24/pl24-budget.service.spec.ts b/apps/api/src/integrations/pl24/pl24-budget.service.spec.ts index 3794149..d20bbf1 100644 --- a/apps/api/src/integrations/pl24/pl24-budget.service.spec.ts +++ b/apps/api/src/integrations/pl24/pl24-budget.service.spec.ts @@ -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"); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24-budget.service.ts b/apps/api/src/integrations/pl24/pl24-budget.service.ts index 714b1cb..cf947ec 100644 --- a/apps/api/src/integrations/pl24/pl24-budget.service.ts +++ b/apps/api/src/integrations/pl24/pl24-budget.service.ts @@ -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 }