Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
67 lines
2.0 KiB
Python
67 lines
2.0 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Per-user concurrent SSE connection gate.
|
|
|
|
SSE connections are long-lived — a client that opens one per tab
|
|
forever can exhaust API workers. Module-level dict + async lock keeps
|
|
the fast path cheap (a dict lookup) while the lock keeps check-and-
|
|
increment atomic across concurrent handshakes.
|
|
|
|
The slot must wrap the generator's own lifetime, not just the handler
|
|
call, because StreamingResponse returns before the generator body
|
|
runs. Call it as the first statement inside the generator — an
|
|
HTTPException raised before the first yield bubbles back to the client
|
|
as a normal HTTP response.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from collections import defaultdict
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
DEFAULT_CAP = 5
|
|
_MAX_PER_USER = int(os.environ.get("DECNET_SSE_MAX_PER_USER", DEFAULT_CAP))
|
|
_counts: dict[str, int] = defaultdict(int)
|
|
_lock: asyncio.Lock | None = None
|
|
|
|
|
|
def _get_lock() -> asyncio.Lock:
|
|
global _lock
|
|
if _lock is None:
|
|
_lock = asyncio.Lock()
|
|
return _lock
|
|
|
|
|
|
def _reset_for_tests() -> None:
|
|
"""Clear counters + lock between tests. The lock is rebuilt lazily
|
|
so a fixture can reset state without worrying about event-loop
|
|
binding from a previous test."""
|
|
global _lock
|
|
_counts.clear()
|
|
_lock = None
|
|
|
|
|
|
def current_count(user_uuid: str) -> int:
|
|
"""Snapshot helper — tests and diagnostics only."""
|
|
return _counts.get(user_uuid, 0)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def sse_connection_slot(user_uuid: str):
|
|
async with _get_lock():
|
|
if _counts[user_uuid] >= _MAX_PER_USER:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail=f"SSE connection limit ({_MAX_PER_USER}) reached",
|
|
)
|
|
_counts[user_uuid] += 1
|
|
try:
|
|
yield
|
|
finally:
|
|
async with _get_lock():
|
|
_counts[user_uuid] -= 1
|
|
if _counts[user_uuid] <= 0:
|
|
del _counts[user_uuid]
|