refactor(api): retire daily talk-time quotas and account auth
This commit is contained in:
+27
-256
@@ -1,43 +1,24 @@
|
||||
"""
|
||||
Tiny server for the speech-to-speech demo.
|
||||
API and WebSocket proxy for the speech-to-speech frontend.
|
||||
|
||||
The demo used to ship as a `sdk: static` Space, but the web-search tool needs a
|
||||
search key the browser must NOT see. A static Space has no runtime process, so it
|
||||
can't hold a secret the front-end uses. This server fixes that: it serves the
|
||||
unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the
|
||||
Serper key server-side (see docs/adr/0001).
|
||||
|
||||
Everything lives in one container; the speech-to-speech backend stays a separate,
|
||||
load-balanced service the browser talks to over WebSocket as before. The load
|
||||
balancer's address is a secret too (like the Serper key): the browser never sees
|
||||
it. `/api/session` proxies the session handshake server-side so only the
|
||||
per-session compute URL the LB hands back (which the browser must dial) is exposed.
|
||||
|
||||
On the deployed Space the server also meters conversation time by HF login tier
|
||||
(anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature
|
||||
is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
|
||||
the live Space, never locally (even with the LB exported for testing).
|
||||
|
||||
`SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is
|
||||
disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
|
||||
browser connects directly to that URL, shown read-only in Settings.
|
||||
Vite serves the React application and forwards backend requests here. Search,
|
||||
caller-profile, and tool credentials stay server-side. `/api/session` proxies
|
||||
load-balancer allocation so the browser receives only the per-session compute
|
||||
URL it must dial. A pinned direct URL or WebSocket proxy target disables the LB.
|
||||
|
||||
Endpoints:
|
||||
GET /api/config -> { search, lb, allowDirect, s2sUrl, auth }
|
||||
GET /api/me -> login + tier + remaining budget (LB mode only)
|
||||
GET /api/config -> frontend settings and available integrations
|
||||
POST /api/search -> { results, answer } Google via Serper.dev
|
||||
POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
|
||||
GET /api/queue/{id} -> proxies <LB>/queue/{id}: position, or a grant on claim
|
||||
DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button)
|
||||
POST /api/queue/end -> leave the queue (sendBeacon on teardown)
|
||||
POST /api/session/heartbeat-> extend the reservation; { expired }
|
||||
POST /api/session/end -> reconcile + refund (sendBeacon on teardown)
|
||||
/* -> static files (index.html, main.js, ...)
|
||||
WS /v1/realtime -> optional speech-service WebSocket proxy
|
||||
GET /docs/* -> shared project documentation
|
||||
|
||||
When every compute slot is busy the load balancer hands back a queue ticket
|
||||
instead of a grant; the browser polls /api/queue/{id} until it reaches the front
|
||||
and a slot frees. Waiting reserves nothing — the daily budget is only reserved at
|
||||
the moment a slot is actually claimed (a grant), never while queued.
|
||||
and a slot frees. Queue deadlines and pending-grant timeouts belong to the LB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,9 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
import auth
|
||||
import limiter
|
||||
|
||||
logger = logging.getLogger("s2s")
|
||||
_logging_configured = False
|
||||
if not _logging_configured:
|
||||
@@ -1402,9 +1380,9 @@ NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip(
|
||||
# browser connects to it straight (no load balancer).
|
||||
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
|
||||
# Direct s2s server URL pinned by the deploy. Takes priority over the load
|
||||
# balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue,
|
||||
# no limiter, no sign-in) and the browser connects to this URL directly. Unlike
|
||||
# the LB address it is NOT a secret — /api/config sends it to the client, which
|
||||
# balancer: when set, LB allocation and queuing are disabled, and the browser
|
||||
# connects to this URL directly. Unlike the LB address it is NOT a secret —
|
||||
# /api/config sends it to the client, which
|
||||
# shows it read-only in Settings.
|
||||
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
|
||||
# Optional same-origin WebSocket relay target. When set, the browser dials this
|
||||
@@ -1413,13 +1391,6 @@ SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
|
||||
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
|
||||
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
|
||||
LOAD_BALANCER_URL = ""
|
||||
# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
|
||||
# locally and on a plain `docker run`. We meter conversation time ONLY on the
|
||||
# deployed Space — i.e. when BOTH the LB is configured AND we're on a Space.
|
||||
# Off-Space (local dev, even with the LB exported) the app still proxies the LB,
|
||||
# but nothing is metered: no budget, no reservations, no sign-in gating.
|
||||
SPACE_ID = os.environ.get("SPACE_ID", "").strip()
|
||||
LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID)
|
||||
SERPER_URL = "https://google.serper.dev/search"
|
||||
# Cap results so the tool output stays small enough to feed back to the model.
|
||||
MAX_RESULTS = 5
|
||||
@@ -1427,10 +1398,6 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
app = FastAPI(title="s2s-demo")
|
||||
|
||||
# Wire HF OAuth before the app serves (no-op unless the OAuth env is present).
|
||||
# Sign-in only matters when we're metering (prod Space), so gate it on that.
|
||||
AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app)
|
||||
|
||||
# Resolve the execution URL for the startup banner using the same normalization
|
||||
# as _apipilot_resolve_execute_url.
|
||||
def _resolve_exec_url_for_startup_banner():
|
||||
@@ -1444,7 +1411,7 @@ _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
|
||||
del _resolve_exec_url_for_startup_banner
|
||||
|
||||
logger.info(
|
||||
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s auth=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
|
||||
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
|
||||
S2S_REASONING_TYPE,
|
||||
NODE_RED_REASONING_URL or "-",
|
||||
bool(SERPER_KEY),
|
||||
@@ -1464,7 +1431,6 @@ logger.info(
|
||||
S2S_DEBUG_PROMPT,
|
||||
S2S_START_SESSION_MODE or "-",
|
||||
S2S_THEME or "-",
|
||||
AUTH_ENABLED,
|
||||
S2S_APIPILOT_TOOLS_URL or "-",
|
||||
(S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})") if S2S_APIPILOT_TOOLS_EXECUTE_URL else "-",
|
||||
"Y" if bool(S2S_APIPILOT_SERVICE_TOKEN) else "-",
|
||||
@@ -1474,10 +1440,6 @@ logger.info(
|
||||
("YES head=" + (S2S_APIPILOT_API_KEY[:6] + "..." if len(S2S_APIPILOT_API_KEY) > 6 else S2S_APIPILOT_API_KEY) + " (3 FORMS!)") if S2S_APIPILOT_API_KEY else "-",
|
||||
)
|
||||
del _STARTUP_APIPILOT_EXEC_URL
|
||||
logger.debug(
|
||||
"[STARTUP] Runtime config — SPACE_ID=%s LIMITER_ENABLED=%s MAX_RESULTS=%d",
|
||||
bool(SPACE_ID), LIMITER_ENABLED, MAX_RESULTS,
|
||||
)
|
||||
|
||||
|
||||
async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None:
|
||||
@@ -1652,22 +1614,6 @@ _NOISY_CLIENT_EVENT_TYPES = frozenset({
|
||||
"input_audio_buffer.speech_started",
|
||||
"input_audio_buffer.speech_stopped",
|
||||
})
|
||||
@app.on_event("startup")
|
||||
async def _startup():
|
||||
"""Stand up the usage DB and a periodic sweeper — metered (prod Space) only."""
|
||||
if not LIMITER_ENABLED:
|
||||
return
|
||||
limiter.init()
|
||||
asyncio.create_task(_sweeper())
|
||||
|
||||
|
||||
async def _sweeper():
|
||||
while True:
|
||||
await asyncio.sleep(limiter.REAP_AFTER_SEC)
|
||||
try:
|
||||
await asyncio.to_thread(limiter.sweep)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("usage sweep failed: %r", exc)
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
@@ -3334,8 +3280,8 @@ def _effective_s2s_url(request: Request) -> str:
|
||||
@app.get("/api/config")
|
||||
async def config(request: Request):
|
||||
"""Client bootstrap: whether web search is available, whether the deploy runs
|
||||
behind a load balancer (so the browser uses the /api/session proxy + limiter),
|
||||
whether HF sign-in is available, and whether the user may instead set a direct
|
||||
behind a load balancer (so the browser uses the /api/session proxy),
|
||||
and whether the user may instead set a direct
|
||||
s2s server URL. The LB address itself is intentionally NOT included.
|
||||
|
||||
2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the
|
||||
@@ -3351,7 +3297,6 @@ async def config(request: Request):
|
||||
"s2sUrl": _effective_s2s_url(request),
|
||||
"primaryPrompt": primary_prompt,
|
||||
"reasoningType": S2S_REASONING_TYPE,
|
||||
"auth": AUTH_ENABLED,
|
||||
"injectProfile": S2S_INJECT_PROFILE,
|
||||
"debugPrompt": S2S_DEBUG_PROMPT,
|
||||
"startSessionMode": S2S_START_SESSION_MODE,
|
||||
@@ -5029,40 +4974,6 @@ async def user_reports(phoneNumber: str | None = None):
|
||||
return JSONResponse(resp_body)
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
async def me(request: Request):
|
||||
"""Login state, tier, and remaining daily budget. Only meaningful in LB mode;
|
||||
sets the anonymous tracking cookie when first seen."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
logger.debug(
|
||||
"[INTERNAL] /api/me client=%s LIMITER=%s",
|
||||
client_ip, LIMITER_ENABLED,
|
||||
)
|
||||
if not LIMITER_ENABLED:
|
||||
return {"enabled": False}
|
||||
view = auth.user_view(request)
|
||||
tier, keys, set_cookie = auth.resolve_identity(request)
|
||||
unlimited = limiter.budget_for(tier) is None
|
||||
rem = None if unlimited else await asyncio.to_thread(limiter.remaining, keys, tier)
|
||||
logger.debug(
|
||||
"[INTERNAL] /api/me client=%s tier=%s unlimited=%s remaining=%s setCookie=%s",
|
||||
client_ip, tier, unlimited, rem, bool(set_cookie),
|
||||
)
|
||||
out = {
|
||||
"enabled": True,
|
||||
"auth": AUTH_ENABLED,
|
||||
**view,
|
||||
"remainingSec": rem,
|
||||
"limitSec": limiter.budget_for(tier),
|
||||
"loginUrl": auth.OAUTH_LOGIN_PATH if AUTH_ENABLED else None,
|
||||
"logoutUrl": auth.OAUTH_LOGOUT_PATH if AUTH_ENABLED else None,
|
||||
}
|
||||
resp = JSONResponse(out)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/api/search")
|
||||
async def search(req: SearchRequest):
|
||||
"""Proxy a Google search via Serper.dev. The key stays on the server unless
|
||||
@@ -5136,44 +5047,17 @@ async def search(req: SearchRequest):
|
||||
|
||||
@app.post("/api/session")
|
||||
async def session(request: Request):
|
||||
"""Proxy the session handshake to the load balancer, keeping its URL secret,
|
||||
and meter conversation time by tier.
|
||||
"""Relay a session grant or queue ticket, keeping the LB address server-side.
|
||||
|
||||
The browser POSTs here (same-origin); we resolve the caller's tier, refuse if
|
||||
today's budget is already spent (402), otherwise POST <LOAD_BALANCER_URL>/session
|
||||
and relay the JSON back. The LB body carries a per-session `connect_url`
|
||||
(compute host + short-lived token) the browser must dial directly — that one
|
||||
URL is unavoidably exposed, but the stable load-balancer address is not. On a
|
||||
successful grant we reserve the first time chunk against the day's budget."""
|
||||
The grant's `connect_url` contains the compute host and short-lived token the
|
||||
browser needs to connect directly to the allocated speech service."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
if not LOAD_BALANCER_URL:
|
||||
# No LB configured — this deploy is direct-mode only; the browser should
|
||||
# never call this. 404 so it's indistinguishable from a missing route.
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
|
||||
tier, keys, set_cookie = auth.resolve_identity(request)
|
||||
# Metering runs only on the deployed Space; off-Space the LB still proxies but
|
||||
# nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either.
|
||||
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
|
||||
|
||||
logger.debug(
|
||||
"[EXT][LB] session: client=%s tier=%s tracked=%s LIMITER=%s",
|
||||
client_ip, tier, tracked, LIMITER_ENABLED,
|
||||
)
|
||||
|
||||
# Refuse before troubling the LB if the day's budget is already gone. Done
|
||||
# here (at enqueue) so we never put a user who can't talk into the queue.
|
||||
if tracked:
|
||||
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
|
||||
logger.debug("[EXT][LB] session: client=%s budget remaining=%s", client_ip, rem)
|
||||
if rem is not None and rem <= 0:
|
||||
resp = JSONResponse(
|
||||
{"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402
|
||||
)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
|
||||
logger.debug("[EXT][LB] session: client=%s", client_ip)
|
||||
url = f"{LOAD_BALANCER_URL.rstrip('/')}/session"
|
||||
logger.debug("[EXT][LB] session POST %s (handshake)", url)
|
||||
try:
|
||||
@@ -5193,10 +5077,7 @@ async def session(request: Request):
|
||||
body = _safe_json(lb)
|
||||
if body.get("state") == "at_capacity":
|
||||
logger.debug("[EXT][LB] session: at_capacity — queue full")
|
||||
resp = JSONResponse({"state": "at_capacity"}, status_code=503)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
return JSONResponse({"state": "at_capacity"}, status_code=503)
|
||||
|
||||
if lb.status_code != 200:
|
||||
# The LB's error body may name the reason (e.g. capacity); it carries no
|
||||
@@ -5213,36 +5094,24 @@ async def session(request: Request):
|
||||
state, session_id, bool(connect_url), connect_url,
|
||||
)
|
||||
|
||||
# Busy pool: the LB queued us. Relay the ticket untouched — crucially with NO
|
||||
# reservation, so waiting in line never costs the day's budget.
|
||||
# Busy pool: the LB queued us. Relay the ticket for subsequent polling.
|
||||
if data.get("state") == "queued":
|
||||
data["tier"] = tier
|
||||
logger.debug("[EXT][LB] session: queued queueId=%s position=%s",
|
||||
data.get("queue_id") or data.get("queueId"),
|
||||
data.get("position"))
|
||||
resp = JSONResponse(data)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
|
||||
# A slot was free: reserve the first chunk now and return the grant.
|
||||
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@app.get("/api/queue/{queue_id}")
|
||||
async def queue_status(queue_id: str, request: Request):
|
||||
"""Poll a waiting ticket: relay the position, or — when the head of the line
|
||||
claims a freed slot — reserve the budget now and return the grant. Re-checks the
|
||||
daily budget at claim, since a multi-minute wait could have spent it elsewhere."""
|
||||
"""Relay a waiting ticket's position or its newly claimed session grant."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
if not LOAD_BALANCER_URL:
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
|
||||
tier, keys, set_cookie = auth.resolve_identity(request)
|
||||
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
|
||||
logger.debug(
|
||||
"[EXT][LB] queue poll: client=%s queueId=%s tier=%s",
|
||||
client_ip, queue_id[:16], tier,
|
||||
"[EXT][LB] queue poll: client=%s queueId=%s",
|
||||
client_ip, queue_id[:16],
|
||||
)
|
||||
|
||||
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
||||
@@ -5262,10 +5131,7 @@ async def queue_status(queue_id: str, request: Request):
|
||||
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
|
||||
# to start over rather than spin.
|
||||
logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
|
||||
resp = JSONResponse({"state": "expired"}, status_code=404)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
return JSONResponse({"state": "expired"}, status_code=404)
|
||||
|
||||
if lb.status_code != 200:
|
||||
logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
|
||||
@@ -5280,31 +5146,7 @@ async def queue_status(queue_id: str, request: Request):
|
||||
data.get("session_id") or data.get("sessionId"),
|
||||
)
|
||||
|
||||
if data.get("state") == "queued":
|
||||
data["tier"] = tier
|
||||
resp = JSONResponse(data)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
|
||||
# Claimed a slot. Re-check the budget: it may have been spent in another tab
|
||||
# during the wait. If so, refuse — the just-claimed slot is now a pending
|
||||
# session on the LB and its pending-timeout reaper reclaims it shortly.
|
||||
if tracked:
|
||||
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
|
||||
logger.debug(
|
||||
"[EXT][LB] queue CLAIMED queueId=%s budget remaining=%s",
|
||||
queue_id[:16], rem,
|
||||
)
|
||||
if rem is not None and rem <= 0:
|
||||
resp = JSONResponse(
|
||||
{"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402
|
||||
)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
|
||||
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@app.delete("/api/queue/{queue_id}")
|
||||
@@ -5330,35 +5172,6 @@ async def queue_end(request: Request):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _finalize_grant(data, keys, tier, tracked, set_cookie):
|
||||
"""Shared grant tail (fast path or queue claim): reserve the first chunk, attach
|
||||
the metering fields the client needs, and set the anon cookie."""
|
||||
remaining = None
|
||||
session_id = data.get("session_id")
|
||||
if tracked and session_id:
|
||||
logger.debug(
|
||||
"[EXT][LB] grant FINALIZE sessionId=%s tier=%s tracked=%s",
|
||||
session_id, tier, tracked,
|
||||
)
|
||||
await asyncio.to_thread(limiter.begin, session_id, keys, tier)
|
||||
remaining = await asyncio.to_thread(limiter.remaining, keys, tier)
|
||||
logger.debug(
|
||||
"[EXT][LB] grant RESERVED sessionId=%s remaining=%s",
|
||||
session_id, remaining,
|
||||
)
|
||||
|
||||
data.update({
|
||||
"tier": tier,
|
||||
"limited": tracked,
|
||||
"remainingSec": remaining,
|
||||
"heartbeatSec": limiter.HEARTBEAT_SEC,
|
||||
})
|
||||
resp = JSONResponse(data)
|
||||
if set_cookie:
|
||||
auth.set_anon_cookie(resp, set_cookie)
|
||||
return resp
|
||||
|
||||
|
||||
async def _lb_leave(queue_id: str) -> None:
|
||||
"""Best-effort: tell the LB to drop a waiting ticket."""
|
||||
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
||||
@@ -6480,48 +6293,6 @@ async def _queue_id(request: Request) -> str:
|
||||
return (data or {}).get("queueId", "") if isinstance(data, dict) else ""
|
||||
|
||||
|
||||
async def _session_id(request: Request) -> str:
|
||||
"""Pull `sessionId` from a JSON body, tolerating sendBeacon's blob posts."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return ""
|
||||
return (data or {}).get("sessionId", "") if isinstance(data, dict) else ""
|
||||
|
||||
|
||||
@app.post("/api/session/heartbeat")
|
||||
async def session_heartbeat(request: Request):
|
||||
"""Extend the live reservation one chunk at a time. `expired` once the day's
|
||||
budget is spent — the client then tears down."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
if not LIMITER_ENABLED:
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
sid = await _session_id(request)
|
||||
alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid)
|
||||
logger.debug(
|
||||
"[INTERNAL] /api/session/heartbeat client=%s sessionId=%s alive=%s",
|
||||
client_ip, (sid or "")[:16], alive,
|
||||
)
|
||||
return {"expired": not alive}
|
||||
|
||||
|
||||
@app.post("/api/session/end")
|
||||
async def session_end(request: Request):
|
||||
"""Clean teardown: reconcile to real elapsed time and refund the unused
|
||||
chunk. Sent via navigator.sendBeacon, so it must succeed without a response."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
if not LIMITER_ENABLED:
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
sid = await _session_id(request)
|
||||
logger.debug(
|
||||
"[INTERNAL] /api/session/end client=%s sessionId=%s",
|
||||
client_ip, (sid or "")[:16],
|
||||
)
|
||||
if sid:
|
||||
await asyncio.to_thread(limiter.end, sid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache_static_assets(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
Reference in New Issue
Block a user