Costul raportat de CLI e pretul echivalent la API; pe abonament nu se factureaza, deci plafonul zilnic oprea puntea fara motiv (5.71 / 5.00 USD). - limits.parse_cap(): `off`/`none`/`nelimitat`/`0`/gol/gunoi => fara plafon - stopped()/record_cost() nu mai opresc si nu mai alerteaza cand e dezactivat - implicit devine `off`; /status arata „(fara plafon)" - dashboard: /api/status si doctor nu mai crapa pe valoare ne-numerica - README + ops/env.example explica de ce ramane off pe abonament Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
140 lines
4.1 KiB
Python
140 lines
4.1 KiB
Python
"""T8: 4 procese, coada per fir, rate limit, plafon de cost."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
import limits as L
|
|
|
|
|
|
def mkl(store=None, **kw):
|
|
kw.setdefault("alerter", lambda *a, **k: None)
|
|
return L.Limits(store, **kw)
|
|
|
|
|
|
async def test_maxim_4_procese_al_cincilea_asteapta():
|
|
lim = mkl(max_procs=4)
|
|
held = []
|
|
ev = asyncio.Event()
|
|
|
|
async def hold(i):
|
|
async with lim.process_slot():
|
|
held.append(i)
|
|
await ev.wait()
|
|
|
|
tasks = [asyncio.create_task(hold(i)) for i in range(5)]
|
|
await asyncio.sleep(0.05)
|
|
assert len(held) == 4 and lim.free_slots == 0
|
|
ev.set()
|
|
await asyncio.gather(*tasks)
|
|
assert len(held) == 5 and lim.free_slots == 4
|
|
|
|
|
|
async def test_coada_per_fir_serializeaza_acelasi_fir():
|
|
lim = mkl()
|
|
ordine = []
|
|
|
|
async def tur(tag, delay):
|
|
async with lim.thread_lock("1"):
|
|
ordine.append(f"start-{tag}")
|
|
await asyncio.sleep(delay)
|
|
ordine.append(f"stop-{tag}")
|
|
|
|
await asyncio.gather(tur("a", 0.02), tur("b", 0.0))
|
|
assert ordine == ["start-a", "stop-a", "start-b", "stop-b"]
|
|
|
|
|
|
async def test_fire_diferite_merg_in_paralel():
|
|
lim = mkl()
|
|
async with lim.thread_lock("1"):
|
|
assert lim.queued("1") and not lim.queued("2")
|
|
async with lim.thread_lock("2"):
|
|
pass
|
|
|
|
|
|
def test_rate_limit_per_user():
|
|
t = [1000.0]
|
|
lim = mkl(rate_per_min=3, clock=lambda: t[0])
|
|
for _ in range(3):
|
|
lim.admit("u1")
|
|
with pytest.raises(L.RateLimited) as exc:
|
|
lim.admit("u1")
|
|
assert exc.value.retry_after == pytest.approx(60.0)
|
|
lim.admit("u2") # alt utilizator nu e afectat
|
|
t[0] += 61.0
|
|
lim.admit("u1") # fereastra a trecut
|
|
|
|
|
|
def test_plafon_de_cost_opreste_botul(store):
|
|
lim = mkl(store, cost_cap=1.0)
|
|
assert not lim.stopped() and lim.cost_remaining() == pytest.approx(1.0)
|
|
lim.record_cost(0.4, "1")
|
|
lim.admit("u1")
|
|
assert lim.record_cost(0.7, "1") == pytest.approx(1.1)
|
|
assert lim.stopped() and lim.cost_remaining() == 0.0
|
|
with pytest.raises(L.CostCapReached):
|
|
lim.admit("u1")
|
|
|
|
|
|
def test_plafonul_off_nu_opreste_nimic(store, state_dir):
|
|
import config
|
|
(state_dir / "env").write_text("COST_CAP_USD_DAY=off\n")
|
|
config.reload(state_dir)
|
|
alerte = []
|
|
lim = L.Limits(store, alerter=lambda *a, **k: alerte.append(a))
|
|
assert lim.cost_cap == 0.0 and not lim.cap_enabled
|
|
lim.record_cost(99.0, "1")
|
|
assert not lim.stopped() and lim.cost_remaining() == float("inf")
|
|
lim.admit("u1") # nu ridica CostCapReached
|
|
assert alerte == []
|
|
|
|
|
|
@pytest.mark.parametrize("raw, asteptat", [
|
|
("off", 0.0), ("", 0.0), ("nelimitat", 0.0), ("0", 0.0), ("-3", 0.0),
|
|
("5.00", 5.0), (" 2.5 ", 2.5), ("gunoi", 0.0), (None, 0.0),
|
|
])
|
|
def test_parse_cap(raw, asteptat):
|
|
assert L.parse_cap(raw, 0.0) == pytest.approx(asteptat)
|
|
|
|
|
|
def test_plafon_alerteaza_o_singura_data(store):
|
|
alerte = []
|
|
lim = mkl(store, cost_cap=0.5, alerter=lambda *a, **k: alerte.append(a))
|
|
lim.record_cost(0.6, "1")
|
|
lim.record_cost(0.1, "1")
|
|
assert len(alerte) == 1 and alerte[0][0] == "CRITICAL"
|
|
|
|
|
|
def test_plafonul_se_ridica_a_doua_zi(store):
|
|
lim = mkl(store, cost_cap=1.0)
|
|
lim.record_cost(2.0, "1")
|
|
assert lim.stopped()
|
|
store.state["cost"]["day"] = "2000-01-01" # ziua se schimba
|
|
assert not lim.stopped()
|
|
|
|
|
|
def test_costul_fara_store_e_local():
|
|
lim = mkl(None, cost_cap=1.0)
|
|
lim.record_cost(0.5)
|
|
lim.record_cost("gunoi")
|
|
assert lim.cost_today() == pytest.approx(0.5)
|
|
|
|
|
|
def test_valorile_implicite_vin_din_config(state_dir):
|
|
import config
|
|
(state_dir / "env").write_text("COST_CAP_USD_DAY=2.5\nMAX_PROCS=2 # comentariu\n")
|
|
config.reload(state_dir)
|
|
lim = mkl()
|
|
assert lim.cost_cap == 2.5 and lim.max_procs == 2
|
|
assert lim.turn_timeout == 900.0
|
|
|
|
|
|
async def test_contextul_turn_verifica_tot(store):
|
|
lim = mkl(store, max_procs=1, cost_cap=10.0, turn_timeout=42.0)
|
|
async with lim.turn("1", "u1") as timeout:
|
|
assert timeout == 42.0
|
|
assert lim.free_slots == 0
|
|
assert lim.free_slots == 1
|