feat(B-WS): WebSocket Helpers + Redis Pub/Sub + Error-Handling
Check Cross-Plugin Imports / check (push) Has been cancelled

B-WS: app/core/ws_helpers.py (NEU) — gemeinsame WebSocket Helpers
- authenticate_ws: Session-Auth für WebSocket (Cookie/Token → User/Tenant)
- check_ws_origin: Origin-Check (delegiert auf verify_ws_origin)
- check_ws_tenant: User-Tenant-Membership-Check
- cleanup_ws_connection: Connection aus Registry entfernen + WS schließen
- start_heartbeat: Background Ping-Task
- send_ws_error: strukturierte Error-Message an Client
- handle_ws_message: Message-Dispatch mit Error-Handling

B-WS: app/core/ws_pubsub.py (NEU) — Redis Pub/Sub für Multi-Worker-Fanout
- publish_to_channel / subscribe_to_channel
- get_tenant_channel / broadcast_to_tenants

B-WS: WebSocketManager + AIUIControlWSManager angepasst
- connect() nutzt authenticate_ws + check_ws_origin + check_ws_tenant
- disconnect() nutzt cleanup_ws_connection + cancelt Heartbeat/PubSub
- broadcast() unterstützt Redis Pub/Sub Fanout

B-ERR-WS: WS Error-Handling in ws_helpers integriert
- send_ws_error für strukturierte Errors
- handle_ws_message fängt Handler-Exceptions

B-WS-TEST: 24 Tests in test_ws_helpers.py — alle grün
- Auth, Origin, Error, Dispatch, Cleanup, Heartbeat, Pub/Sub Roundtrip
- Keine Regression: 47/47 Resilience+Hooks Tests grün
This commit is contained in:
Agent Zero
2026-08-13 16:43:54 +02:00
parent a3a26d1f66
commit 7a81a5f072
7 changed files with 969 additions and 76 deletions
+65
View File
@@ -0,0 +1,65 @@
"""Redis Pub/Sub helpers for WebSocket multi-worker fanout."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Awaitable, Callable
from app.core.auth import get_redis
logger = logging.getLogger(__name__)
async def publish_to_channel(channel: str, message: dict) -> None:
"""Publish a JSON message to a Redis channel."""
redis = get_redis()
await redis.publish(channel, json.dumps(message, default=str))
async def subscribe_to_channel(
channel: str,
handler: Callable[[dict], Awaitable[None]],
) -> asyncio.Task:
"""Subscribe to a Redis channel and call *handler* for every message.
Returns the :class:`asyncio.Task` so the caller can cancel it on disconnect.
"""
async def _subscriber() -> None:
redis = get_redis()
pubsub = redis.pubsub()
await pubsub.subscribe(channel)
try:
async for raw in pubsub.listen():
if raw["type"] == "message":
try:
msg = json.loads(raw["data"])
await handler(msg)
except Exception:
logger.exception("Error in pubsub handler for channel %s", channel)
finally:
try:
await pubsub.unsubscribe(channel)
await pubsub.aclose()
except Exception:
logger.debug("PubSub cleanup error for channel %s", channel)
return asyncio.create_task(_subscriber())
def get_tenant_channel(tenant_id: uuid.UUID, topic: str) -> str:
"""Return the Redis channel name for a tenant + topic."""
return f"ws:{tenant_id}:{topic}"
async def broadcast_to_tenants(
tenant_id: uuid.UUID,
topic: str,
message: dict,
) -> None:
"""Publish a message to a tenant-specific Redis channel."""
channel = get_tenant_channel(tenant_id, topic)
await publish_to_channel(channel, message)