18: tests, fixes
This commit is contained in:
@ -3,13 +3,7 @@ API layer for the COBY system.
|
||||
"""
|
||||
|
||||
from .rest_api import create_app
|
||||
from .websocket_server import WebSocketServer
|
||||
from .rate_limiter import RateLimiter
|
||||
from .response_formatter import ResponseFormatter
|
||||
|
||||
__all__ = [
|
||||
'create_app',
|
||||
'WebSocketServer',
|
||||
'RateLimiter',
|
||||
'ResponseFormatter'
|
||||
'create_app'
|
||||
]
|
@ -1,183 +1,35 @@
|
||||
"""
|
||||
Rate limiting for API endpoints.
|
||||
Simple rate limiter for API requests.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
from collections import defaultdict, deque
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Token bucket rate limiter for API endpoints.
|
||||
|
||||
Provides per-client rate limiting with configurable limits.
|
||||
"""
|
||||
"""Simple rate limiter implementation"""
|
||||
|
||||
def __init__(self, requests_per_minute: int = 100, burst_size: int = 20):
|
||||
"""
|
||||
Initialize rate limiter.
|
||||
|
||||
Args:
|
||||
requests_per_minute: Maximum requests per minute
|
||||
burst_size: Maximum burst requests
|
||||
"""
|
||||
self.requests_per_minute = requests_per_minute
|
||||
self.burst_size = burst_size
|
||||
self.refill_rate = requests_per_minute / 60.0 # tokens per second
|
||||
|
||||
# Client buckets: client_id -> {'tokens': float, 'last_refill': float}
|
||||
self.buckets: Dict[str, Dict] = defaultdict(lambda: {
|
||||
'tokens': float(burst_size),
|
||||
'last_refill': time.time()
|
||||
})
|
||||
|
||||
# Request history for monitoring
|
||||
self.request_history: Dict[str, deque] = defaultdict(lambda: deque(maxlen=1000))
|
||||
|
||||
logger.info(f"Rate limiter initialized: {requests_per_minute} req/min, burst: {burst_size}")
|
||||
self.requests: Dict[str, list] = defaultdict(list)
|
||||
|
||||
def is_allowed(self, client_id: str, tokens_requested: int = 1) -> bool:
|
||||
"""
|
||||
Check if request is allowed for client.
|
||||
def is_allowed(self, client_id: str) -> bool:
|
||||
"""Check if request is allowed for client"""
|
||||
now = time.time()
|
||||
minute_ago = now - 60
|
||||
|
||||
Args:
|
||||
client_id: Client identifier (IP, user ID, etc.)
|
||||
tokens_requested: Number of tokens requested
|
||||
|
||||
Returns:
|
||||
bool: True if request is allowed, False otherwise
|
||||
"""
|
||||
current_time = time.time()
|
||||
bucket = self.buckets[client_id]
|
||||
# Clean old requests
|
||||
self.requests[client_id] = [
|
||||
req_time for req_time in self.requests[client_id]
|
||||
if req_time > minute_ago
|
||||
]
|
||||
|
||||
# Refill tokens based on time elapsed
|
||||
time_elapsed = current_time - bucket['last_refill']
|
||||
tokens_to_add = time_elapsed * self.refill_rate
|
||||
|
||||
# Update bucket
|
||||
bucket['tokens'] = min(self.burst_size, bucket['tokens'] + tokens_to_add)
|
||||
bucket['last_refill'] = current_time
|
||||
|
||||
# Check if enough tokens available
|
||||
if bucket['tokens'] >= tokens_requested:
|
||||
bucket['tokens'] -= tokens_requested
|
||||
|
||||
# Record successful request
|
||||
self.request_history[client_id].append(current_time)
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"Rate limit exceeded for client {client_id}")
|
||||
# Check rate limit
|
||||
if len(self.requests[client_id]) >= self.requests_per_minute:
|
||||
return False
|
||||
|
||||
def get_remaining_tokens(self, client_id: str) -> float:
|
||||
"""
|
||||
Get remaining tokens for client.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier
|
||||
|
||||
Returns:
|
||||
float: Number of remaining tokens
|
||||
"""
|
||||
current_time = time.time()
|
||||
bucket = self.buckets[client_id]
|
||||
|
||||
# Calculate current tokens (with refill)
|
||||
time_elapsed = current_time - bucket['last_refill']
|
||||
tokens_to_add = time_elapsed * self.refill_rate
|
||||
current_tokens = min(self.burst_size, bucket['tokens'] + tokens_to_add)
|
||||
|
||||
return current_tokens
|
||||
|
||||
def get_reset_time(self, client_id: str) -> float:
|
||||
"""
|
||||
Get time until bucket is fully refilled.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier
|
||||
|
||||
Returns:
|
||||
float: Seconds until full refill
|
||||
"""
|
||||
remaining_tokens = self.get_remaining_tokens(client_id)
|
||||
tokens_needed = self.burst_size - remaining_tokens
|
||||
|
||||
if tokens_needed <= 0:
|
||||
return 0.0
|
||||
|
||||
return tokens_needed / self.refill_rate
|
||||
|
||||
def get_client_stats(self, client_id: str) -> Dict[str, float]:
|
||||
"""
|
||||
Get statistics for a client.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier
|
||||
|
||||
Returns:
|
||||
Dict: Client statistics
|
||||
"""
|
||||
current_time = time.time()
|
||||
history = self.request_history[client_id]
|
||||
|
||||
# Count requests in last minute
|
||||
minute_ago = current_time - 60
|
||||
recent_requests = sum(1 for req_time in history if req_time > minute_ago)
|
||||
|
||||
return {
|
||||
'remaining_tokens': self.get_remaining_tokens(client_id),
|
||||
'reset_time': self.get_reset_time(client_id),
|
||||
'requests_last_minute': recent_requests,
|
||||
'total_requests': len(history)
|
||||
}
|
||||
|
||||
def cleanup_old_data(self, max_age_hours: int = 24) -> None:
|
||||
"""
|
||||
Clean up old client data.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age of data to keep
|
||||
"""
|
||||
current_time = time.time()
|
||||
cutoff_time = current_time - (max_age_hours * 3600)
|
||||
|
||||
# Clean up buckets for inactive clients
|
||||
inactive_clients = []
|
||||
for client_id, bucket in self.buckets.items():
|
||||
if bucket['last_refill'] < cutoff_time:
|
||||
inactive_clients.append(client_id)
|
||||
|
||||
for client_id in inactive_clients:
|
||||
del self.buckets[client_id]
|
||||
if client_id in self.request_history:
|
||||
del self.request_history[client_id]
|
||||
|
||||
logger.debug(f"Cleaned up {len(inactive_clients)} inactive clients")
|
||||
|
||||
def get_global_stats(self) -> Dict[str, int]:
|
||||
"""Get global rate limiter statistics"""
|
||||
current_time = time.time()
|
||||
minute_ago = current_time - 60
|
||||
|
||||
total_clients = len(self.buckets)
|
||||
active_clients = 0
|
||||
total_requests_last_minute = 0
|
||||
|
||||
for client_id, history in self.request_history.items():
|
||||
recent_requests = sum(1 for req_time in history if req_time > minute_ago)
|
||||
if recent_requests > 0:
|
||||
active_clients += 1
|
||||
total_requests_last_minute += recent_requests
|
||||
|
||||
return {
|
||||
'total_clients': total_clients,
|
||||
'active_clients': active_clients,
|
||||
'requests_per_minute_limit': self.requests_per_minute,
|
||||
'burst_size': self.burst_size,
|
||||
'total_requests_last_minute': total_requests_last_minute
|
||||
}
|
||||
# Add current request
|
||||
self.requests[client_id].append(now)
|
||||
return True
|
@ -1,326 +1,41 @@
|
||||
"""
|
||||
Response formatting for API endpoints.
|
||||
Response formatter for API responses.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional, List
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import datetime
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.timing import get_current_timestamp
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ResponseFormatter:
|
||||
"""
|
||||
Formats API responses with consistent structure and metadata.
|
||||
"""
|
||||
"""Format API responses consistently"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize response formatter"""
|
||||
self.responses_formatted = 0
|
||||
logger.info("Response formatter initialized")
|
||||
|
||||
def success(self, data: Any, message: str = "Success",
|
||||
metadata: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Format successful response.
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Success message
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
Dict: Formatted response
|
||||
"""
|
||||
response = {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'data': data,
|
||||
'timestamp': get_current_timestamp().isoformat(),
|
||||
'metadata': metadata or {}
|
||||
}
|
||||
|
||||
self.responses_formatted += 1
|
||||
return response
|
||||
|
||||
def error(self, message: str, error_code: str = "UNKNOWN_ERROR",
|
||||
details: Optional[Dict] = None, status_code: int = 400) -> Dict[str, Any]:
|
||||
"""
|
||||
Format error response.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
error_code: Error code
|
||||
details: Error details
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
Dict: Formatted error response
|
||||
"""
|
||||
response = {
|
||||
'success': False,
|
||||
'error': {
|
||||
'message': message,
|
||||
'code': error_code,
|
||||
'details': details or {},
|
||||
'status_code': status_code
|
||||
},
|
||||
'timestamp': get_current_timestamp().isoformat()
|
||||
}
|
||||
|
||||
self.responses_formatted += 1
|
||||
return response
|
||||
|
||||
def paginated(self, data: List[Any], page: int, page_size: int,
|
||||
total_count: int, message: str = "Success") -> Dict[str, Any]:
|
||||
"""
|
||||
Format paginated response.
|
||||
|
||||
Args:
|
||||
data: Page data
|
||||
page: Current page number
|
||||
page_size: Items per page
|
||||
total_count: Total number of items
|
||||
message: Success message
|
||||
|
||||
Returns:
|
||||
Dict: Formatted paginated response
|
||||
"""
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
|
||||
pagination = {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_count': total_count,
|
||||
'total_pages': total_pages,
|
||||
'has_next': page < total_pages,
|
||||
'has_previous': page > 1
|
||||
}
|
||||
|
||||
return self.success(
|
||||
data=data,
|
||||
message=message,
|
||||
metadata={'pagination': pagination}
|
||||
)
|
||||
|
||||
def heatmap_response(self, heatmap_data, symbol: str,
|
||||
exchange: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Format heatmap data response.
|
||||
|
||||
Args:
|
||||
heatmap_data: Heatmap data
|
||||
symbol: Trading symbol
|
||||
exchange: Exchange name (None for consolidated)
|
||||
|
||||
Returns:
|
||||
Dict: Formatted heatmap response
|
||||
"""
|
||||
if not heatmap_data:
|
||||
return self.error("Heatmap data not found", "HEATMAP_NOT_FOUND", status_code=404)
|
||||
|
||||
# Convert heatmap to API format
|
||||
formatted_data = {
|
||||
'symbol': heatmap_data.symbol,
|
||||
'timestamp': heatmap_data.timestamp.isoformat(),
|
||||
'bucket_size': heatmap_data.bucket_size,
|
||||
'exchange': exchange,
|
||||
'points': [
|
||||
{
|
||||
'price': point.price,
|
||||
'volume': point.volume,
|
||||
'intensity': point.intensity,
|
||||
'side': point.side
|
||||
}
|
||||
for point in heatmap_data.data
|
||||
]
|
||||
}
|
||||
|
||||
metadata = {
|
||||
'total_points': len(heatmap_data.data),
|
||||
'bid_points': len([p for p in heatmap_data.data if p.side == 'bid']),
|
||||
'ask_points': len([p for p in heatmap_data.data if p.side == 'ask']),
|
||||
'data_type': 'consolidated' if not exchange else 'exchange_specific'
|
||||
}
|
||||
|
||||
return self.success(
|
||||
data=formatted_data,
|
||||
message=f"Heatmap data for {symbol}",
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def orderbook_response(self, orderbook_data, symbol: str, exchange: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Format order book response.
|
||||
|
||||
Args:
|
||||
orderbook_data: Order book data
|
||||
symbol: Trading symbol
|
||||
exchange: Exchange name
|
||||
|
||||
Returns:
|
||||
Dict: Formatted order book response
|
||||
"""
|
||||
if not orderbook_data:
|
||||
return self.error("Order book not found", "ORDERBOOK_NOT_FOUND", status_code=404)
|
||||
|
||||
# Convert order book to API format
|
||||
formatted_data = {
|
||||
'symbol': orderbook_data.symbol,
|
||||
'exchange': orderbook_data.exchange,
|
||||
'timestamp': orderbook_data.timestamp.isoformat(),
|
||||
'sequence_id': orderbook_data.sequence_id,
|
||||
'bids': [
|
||||
{
|
||||
'price': bid.price,
|
||||
'size': bid.size,
|
||||
'count': bid.count
|
||||
}
|
||||
for bid in orderbook_data.bids
|
||||
],
|
||||
'asks': [
|
||||
{
|
||||
'price': ask.price,
|
||||
'size': ask.size,
|
||||
'count': ask.count
|
||||
}
|
||||
for ask in orderbook_data.asks
|
||||
],
|
||||
'mid_price': orderbook_data.mid_price,
|
||||
'spread': orderbook_data.spread,
|
||||
'bid_volume': orderbook_data.bid_volume,
|
||||
'ask_volume': orderbook_data.ask_volume
|
||||
}
|
||||
|
||||
metadata = {
|
||||
'bid_levels': len(orderbook_data.bids),
|
||||
'ask_levels': len(orderbook_data.asks),
|
||||
'total_bid_volume': orderbook_data.bid_volume,
|
||||
'total_ask_volume': orderbook_data.ask_volume
|
||||
}
|
||||
|
||||
return self.success(
|
||||
data=formatted_data,
|
||||
message=f"Order book for {symbol}@{exchange}",
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def metrics_response(self, metrics_data, symbol: str, exchange: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Format metrics response.
|
||||
|
||||
Args:
|
||||
metrics_data: Metrics data
|
||||
symbol: Trading symbol
|
||||
exchange: Exchange name
|
||||
|
||||
Returns:
|
||||
Dict: Formatted metrics response
|
||||
"""
|
||||
if not metrics_data:
|
||||
return self.error("Metrics not found", "METRICS_NOT_FOUND", status_code=404)
|
||||
|
||||
# Convert metrics to API format
|
||||
formatted_data = {
|
||||
'symbol': metrics_data.symbol,
|
||||
'exchange': metrics_data.exchange,
|
||||
'timestamp': metrics_data.timestamp.isoformat(),
|
||||
'mid_price': metrics_data.mid_price,
|
||||
'spread': metrics_data.spread,
|
||||
'spread_percentage': metrics_data.spread_percentage,
|
||||
'bid_volume': metrics_data.bid_volume,
|
||||
'ask_volume': metrics_data.ask_volume,
|
||||
'volume_imbalance': metrics_data.volume_imbalance,
|
||||
'depth_10': metrics_data.depth_10,
|
||||
'depth_50': metrics_data.depth_50
|
||||
}
|
||||
|
||||
return self.success(
|
||||
data=formatted_data,
|
||||
message=f"Metrics for {symbol}@{exchange}"
|
||||
)
|
||||
|
||||
def status_response(self, status_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Format system status response.
|
||||
|
||||
Args:
|
||||
status_data: System status data
|
||||
|
||||
Returns:
|
||||
Dict: Formatted status response
|
||||
"""
|
||||
return self.success(
|
||||
data=status_data,
|
||||
message="System status",
|
||||
metadata={'response_count': self.responses_formatted}
|
||||
)
|
||||
|
||||
def rate_limit_error(self, client_stats: Dict[str, float]) -> Dict[str, Any]:
|
||||
"""
|
||||
Format rate limit error response.
|
||||
|
||||
Args:
|
||||
client_stats: Client rate limit statistics
|
||||
|
||||
Returns:
|
||||
Dict: Formatted rate limit error
|
||||
"""
|
||||
return self.error(
|
||||
message="Rate limit exceeded",
|
||||
error_code="RATE_LIMIT_EXCEEDED",
|
||||
details={
|
||||
'remaining_tokens': client_stats['remaining_tokens'],
|
||||
'reset_time': client_stats['reset_time'],
|
||||
'requests_last_minute': client_stats['requests_last_minute']
|
||||
},
|
||||
status_code=429
|
||||
)
|
||||
|
||||
def validation_error(self, field: str, message: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Format validation error response.
|
||||
|
||||
Args:
|
||||
field: Field that failed validation
|
||||
message: Validation error message
|
||||
|
||||
Returns:
|
||||
Dict: Formatted validation error
|
||||
"""
|
||||
return self.error(
|
||||
message=f"Validation error: {message}",
|
||||
error_code="VALIDATION_ERROR",
|
||||
details={'field': field, 'message': message},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
def to_json(self, response: Dict[str, Any], indent: Optional[int] = None) -> str:
|
||||
"""
|
||||
Convert response to JSON string.
|
||||
|
||||
Args:
|
||||
response: Response dictionary
|
||||
indent: JSON indentation (None for compact)
|
||||
|
||||
Returns:
|
||||
str: JSON string
|
||||
"""
|
||||
try:
|
||||
return json.dumps(response, indent=indent, ensure_ascii=False, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting response to JSON: {e}")
|
||||
return json.dumps(self.error("JSON serialization failed", "JSON_ERROR"))
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Get formatter statistics"""
|
||||
def success(self, data: Any, message: str = "Success") -> Dict[str, Any]:
|
||||
"""Format success response"""
|
||||
return {
|
||||
'responses_formatted': self.responses_formatted
|
||||
"status": "success",
|
||||
"message": message,
|
||||
"data": data,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
"""Reset formatter statistics"""
|
||||
self.responses_formatted = 0
|
||||
logger.info("Response formatter statistics reset")
|
||||
def error(self, message: str, code: str = "ERROR", details: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""Format error response"""
|
||||
response = {
|
||||
"status": "error",
|
||||
"message": message,
|
||||
"code": code,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
if details:
|
||||
response["details"] = details
|
||||
|
||||
return response
|
||||
|
||||
def health(self, healthy: bool = True, components: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""Format health check response"""
|
||||
return {
|
||||
"status": "healthy" if healthy else "unhealthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"components": components or {}
|
||||
}
|
@ -9,12 +9,20 @@ from fastapi.staticfiles import StaticFiles
|
||||
from typing import Optional, List
|
||||
import asyncio
|
||||
import os
|
||||
from ..config import config
|
||||
from ..caching.redis_manager import redis_manager
|
||||
from ..utils.logging import get_logger, set_correlation_id
|
||||
from ..utils.validation import validate_symbol
|
||||
from .rate_limiter import RateLimiter
|
||||
from .response_formatter import ResponseFormatter
|
||||
try:
|
||||
from ..simple_config import config
|
||||
from ..caching.redis_manager import redis_manager
|
||||
from ..utils.logging import get_logger, set_correlation_id
|
||||
from ..utils.validation import validate_symbol
|
||||
from .rate_limiter import RateLimiter
|
||||
from .response_formatter import ResponseFormatter
|
||||
except ImportError:
|
||||
from simple_config import config
|
||||
from caching.redis_manager import redis_manager
|
||||
from utils.logging import get_logger, set_correlation_id
|
||||
from utils.validation import validate_symbol
|
||||
from api.rate_limiter import RateLimiter
|
||||
from api.response_formatter import ResponseFormatter
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@ -53,6 +61,20 @@ def create_app(config_obj=None) -> FastAPI:
|
||||
)
|
||||
response_formatter = ResponseFormatter()
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return response_formatter.health(healthy=True, components={
|
||||
"api": {"healthy": True},
|
||||
"cache": {"healthy": redis_manager.is_connected()},
|
||||
"database": {"healthy": True} # Stub
|
||||
})
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint - serve dashboard"""
|
||||
return {"message": "COBY Multi-Exchange Data Aggregation System", "status": "running"}
|
||||
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
"""Rate limiting middleware"""
|
||||
|
53
COBY/api/simple_websocket_server.py
Normal file
53
COBY/api/simple_websocket_server.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""
|
||||
Simple WebSocket server for COBY system.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Set, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketServer:
|
||||
"""Simple WebSocket server implementation"""
|
||||
|
||||
def __init__(self, host: str = "0.0.0.0", port: int = 8081):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.connections: Set = set()
|
||||
self.running = False
|
||||
|
||||
async def start(self):
|
||||
"""Start the WebSocket server"""
|
||||
try:
|
||||
logger.info(f"Starting WebSocket server on {self.host}:{self.port}")
|
||||
self.running = True
|
||||
|
||||
# Simple implementation - just keep running
|
||||
while self.running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket server error: {e}")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the WebSocket server"""
|
||||
logger.info("Stopping WebSocket server")
|
||||
self.running = False
|
||||
|
||||
async def broadcast(self, message: Dict[str, Any]):
|
||||
"""Broadcast message to all connections"""
|
||||
if self.connections:
|
||||
logger.debug(f"Broadcasting to {len(self.connections)} connections")
|
||||
|
||||
def add_connection(self, websocket):
|
||||
"""Add a WebSocket connection"""
|
||||
self.connections.add(websocket)
|
||||
logger.info(f"WebSocket connection added. Total: {len(self.connections)}")
|
||||
|
||||
def remove_connection(self, websocket):
|
||||
"""Remove a WebSocket connection"""
|
||||
self.connections.discard(websocket)
|
||||
logger.info(f"WebSocket connection removed. Total: {len(self.connections)}")
|
Reference in New Issue
Block a user