django-socket 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_socket/__init__.py +61 -0
- django_socket/apps.py +32 -0
- django_socket/asgi.py +62 -0
- django_socket/auth.py +10 -0
- django_socket/authentication.py +278 -0
- django_socket/checks.py +114 -0
- django_socket/dispatch.py +208 -0
- django_socket/events.py +143 -0
- django_socket/groups.py +287 -0
- django_socket/management/__init__.py +0 -0
- django_socket/management/commands/__init__.py +0 -0
- django_socket/management/commands/runserver.py +81 -0
- django_socket/management/commands/ws.py +64 -0
- django_socket/middleware.py +166 -0
- django_socket/patch.py +54 -0
- django_socket/py.typed +0 -0
- django_socket/ratelimit.py +93 -0
- django_socket/routing.py +148 -0
- django_socket/static/django_socket/client.js +262 -0
- django_socket/templatetags/__init__.py +0 -0
- django_socket/templatetags/django_socket.py +28 -0
- django_socket/testing.py +330 -0
- django_socket/websocket.py +602 -0
- django_socket-0.2.0.dist-info/METADATA +1208 -0
- django_socket-0.2.0.dist-info/RECORD +28 -0
- django_socket-0.2.0.dist-info/WHEEL +5 -0
- django_socket-0.2.0.dist-info/licenses/LICENSE +21 -0
- django_socket-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""El nucleo: atiende los scopes 'websocket' y 'lifespan'.
|
|
2
|
+
|
|
3
|
+
Vive aparte de `asgi.py` porque hay dos caminos que llegan aqui: el parche
|
|
4
|
+
sobre `ASGIHandler` (modo cero-configuracion) y `ASGIApplication` explicito.
|
|
5
|
+
Los dos comparten este codigo.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from asgiref.sync import ThreadSensitiveContext
|
|
14
|
+
|
|
15
|
+
from . import authentication
|
|
16
|
+
from . import middleware as mw
|
|
17
|
+
from . import groups, routing
|
|
18
|
+
from .websocket import InvalidJSON, RateLimited, WebSocket, WebSocketDisconnect
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("django_socket")
|
|
21
|
+
|
|
22
|
+
# Codigos de cierre propios (rango privado 4000-4999).
|
|
23
|
+
CLOSE_NO_ROUTE = 4404
|
|
24
|
+
CLOSE_BAD_DATA = 4400 # el cliente mando algo que no se puede parsear
|
|
25
|
+
CLOSE_SERVER_ERROR = 1011
|
|
26
|
+
|
|
27
|
+
_layer_started = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _settings():
|
|
31
|
+
from django.conf import settings
|
|
32
|
+
|
|
33
|
+
return getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _start_layer() -> None:
|
|
37
|
+
global _layer_started
|
|
38
|
+
if not _layer_started:
|
|
39
|
+
await groups.get_layer().startup()
|
|
40
|
+
_layer_started = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _stop_layer() -> None:
|
|
44
|
+
global _layer_started
|
|
45
|
+
if _layer_started:
|
|
46
|
+
await groups.get_layer().shutdown()
|
|
47
|
+
_layer_started = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------- lifespan
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def handle_lifespan(scope, receive, send) -> None:
|
|
54
|
+
while True:
|
|
55
|
+
message = await receive()
|
|
56
|
+
if message["type"] == "lifespan.startup":
|
|
57
|
+
try:
|
|
58
|
+
await _start_layer()
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.exception("django_socket: fallo en el arranque")
|
|
61
|
+
await send({"type": "lifespan.startup.failed", "message": str(exc)})
|
|
62
|
+
return
|
|
63
|
+
await send({"type": "lifespan.startup.complete"})
|
|
64
|
+
elif message["type"] == "lifespan.shutdown":
|
|
65
|
+
try:
|
|
66
|
+
await _stop_layer()
|
|
67
|
+
finally:
|
|
68
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------- websocket
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def handle_websocket(scope, receive, send) -> None:
|
|
76
|
+
# El primer evento del protocolo siempre es websocket.connect.
|
|
77
|
+
event = await receive()
|
|
78
|
+
if event["type"] != "websocket.connect":
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
await _start_layer() # por si el servidor no soporta lifespan
|
|
82
|
+
sock = WebSocket(scope, receive, send, layer=groups.get_layer())
|
|
83
|
+
|
|
84
|
+
if not origin_allowed(sock):
|
|
85
|
+
logger.warning(
|
|
86
|
+
"django_socket: origen rechazado %r para %s",
|
|
87
|
+
sock.headers.get("origin"),
|
|
88
|
+
sock.path,
|
|
89
|
+
)
|
|
90
|
+
# En seco: un origen ajeno no debe tener un socket abierto ni un instante.
|
|
91
|
+
await sock.deny()
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
match = routing.resolve(sock.path)
|
|
95
|
+
if match is None:
|
|
96
|
+
logger.warning(
|
|
97
|
+
"django_socket: ninguna ruta casa con %s. Registradas: %s",
|
|
98
|
+
sock.path,
|
|
99
|
+
", ".join(f"/{r.route}" for r in routing.get_routes()) or "(ninguna)",
|
|
100
|
+
)
|
|
101
|
+
await sock.close(CLOSE_NO_ROUTE, "No route")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
route, kwargs = match
|
|
105
|
+
sock.path_params = kwargs
|
|
106
|
+
|
|
107
|
+
async with ThreadSensitiveContext():
|
|
108
|
+
if route.auth is not False:
|
|
109
|
+
await authentication.resolve(sock, route.auth)
|
|
110
|
+
|
|
111
|
+
from . import ratelimit
|
|
112
|
+
|
|
113
|
+
sock._rate = ratelimit.crear(route.rate_limit, route.burst)
|
|
114
|
+
|
|
115
|
+
async def ejecutar():
|
|
116
|
+
if route.group:
|
|
117
|
+
# group="room:{room}" se rellena con los parametros de la ruta.
|
|
118
|
+
await sock.join(route.group.format(**kwargs))
|
|
119
|
+
await route.handler(sock, **kwargs)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# El middleware va por fuera del handler pero por dentro de este
|
|
123
|
+
# try, para que un fallo suyo se trate igual que uno del handler.
|
|
124
|
+
await mw.aplicar(sock, ejecutar)
|
|
125
|
+
except WebSocketDisconnect:
|
|
126
|
+
pass # el cliente se fue; salida normal
|
|
127
|
+
except RateLimited as exc:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"django_socket: %s va demasiado rapido en %s (%s)",
|
|
130
|
+
sock.client, sock.path, exc,
|
|
131
|
+
)
|
|
132
|
+
await sock.close(exc.code, f"Rate limit; retry in {exc.espera:.0f}s")
|
|
133
|
+
return
|
|
134
|
+
except InvalidJSON as exc:
|
|
135
|
+
# Culpa del cliente, no del servidor: un aviso y un codigo que lo
|
|
136
|
+
# diga. Nada de traceback ni de 1011, que harian pensar que el bug
|
|
137
|
+
# es tuyo cada vez que alguien mande basura por el socket.
|
|
138
|
+
logger.warning(
|
|
139
|
+
"django_socket: %s en %s (cliente %s)", exc, sock.path, sock.client
|
|
140
|
+
)
|
|
141
|
+
await sock.close(CLOSE_BAD_DATA, "Invalid JSON")
|
|
142
|
+
return
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.exception("django_socket: excepcion en el handler de %s", sock.path)
|
|
145
|
+
await sock.close(CLOSE_SERVER_ERROR, "Internal error")
|
|
146
|
+
return
|
|
147
|
+
await sock.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------ origen
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def origin_allowed(sock) -> bool:
|
|
154
|
+
"""
|
|
155
|
+
Los WebSockets no estan sujetos a la politica de mismo origen: sin esta
|
|
156
|
+
comprobacion cualquier web podria abrir un socket autenticado contra la
|
|
157
|
+
tuya (cross-site WebSocket hijacking).
|
|
158
|
+
|
|
159
|
+
Un Origin ausente se acepta: los navegadores siempre lo mandan, asi que
|
|
160
|
+
solo lo omiten clientes nativos. Ponlo estricto con REQUIRE_ORIGIN.
|
|
161
|
+
"""
|
|
162
|
+
from django.conf import settings
|
|
163
|
+
|
|
164
|
+
conf = _settings()
|
|
165
|
+
origin = sock.headers.get("origin")
|
|
166
|
+
if origin is None:
|
|
167
|
+
return not conf.get("REQUIRE_ORIGIN", False)
|
|
168
|
+
|
|
169
|
+
allowed = conf.get("ALLOWED_ORIGINS")
|
|
170
|
+
if allowed is not None:
|
|
171
|
+
if "*" in allowed:
|
|
172
|
+
return True
|
|
173
|
+
return origin in allowed or _host_of(origin) in allowed
|
|
174
|
+
|
|
175
|
+
# Por defecto: ALLOWED_HOSTS + CSRF_TRUSTED_ORIGINS, como hace Django.
|
|
176
|
+
host = _host_of(origin)
|
|
177
|
+
if not host:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
trusted = {
|
|
181
|
+
_host_of(o) for o in getattr(settings, "CSRF_TRUSTED_ORIGINS", []) or []
|
|
182
|
+
}
|
|
183
|
+
if host in trusted:
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
hosts = list(getattr(settings, "ALLOWED_HOSTS", []) or [])
|
|
187
|
+
if settings.DEBUG and not hosts:
|
|
188
|
+
hosts = ["localhost", "127.0.0.1", "[::1]"]
|
|
189
|
+
return any(_host_matches(host, pattern) for pattern in hosts)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _host_of(origin: str) -> str:
|
|
193
|
+
"""'https://ejemplo.com:8000' -> 'ejemplo.com'."""
|
|
194
|
+
try:
|
|
195
|
+
return (urlparse(origin).hostname or "").lower()
|
|
196
|
+
except ValueError:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _host_matches(host: str, pattern: str) -> bool:
|
|
201
|
+
pattern = pattern.lower()
|
|
202
|
+
if pattern == "*":
|
|
203
|
+
return True
|
|
204
|
+
if pattern.startswith("."): # ".ejemplo.com" cubre subdominios y el apex
|
|
205
|
+
return host == pattern[1:] or host.endswith(pattern)
|
|
206
|
+
if pattern.startswith("*."):
|
|
207
|
+
return host == pattern[2:] or host.endswith(pattern[1:])
|
|
208
|
+
return host == pattern
|
django_socket/events.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Enrutado de mensajes JSON por tipo.
|
|
2
|
+
|
|
3
|
+
Casi toda app que habla JSON por el socket manda `{"type": "algo", ...}` y
|
|
4
|
+
acaba con un if/elif largo dentro del bucle. Esto lo convierte en funciones con
|
|
5
|
+
nombre, sin que dejes de ver el flujo:
|
|
6
|
+
|
|
7
|
+
from django_socket import Events, ws
|
|
8
|
+
|
|
9
|
+
chat = Events()
|
|
10
|
+
|
|
11
|
+
@chat.on("mensaje")
|
|
12
|
+
async def mensaje(sock, datos):
|
|
13
|
+
await sock.broadcast({"type": "mensaje", "texto": datos["texto"]})
|
|
14
|
+
|
|
15
|
+
@chat.on("escribiendo")
|
|
16
|
+
async def escribiendo(sock): # si no usas los datos, no los pidas
|
|
17
|
+
await sock.broadcast({"type": "escribiendo"}, exclude_self=True)
|
|
18
|
+
|
|
19
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
20
|
+
async def handler(sock, room):
|
|
21
|
+
await chat.run(sock)
|
|
22
|
+
|
|
23
|
+
Es opcional del todo: `async for msg in sock` sigue estando ahi y no necesitas
|
|
24
|
+
saber que esto existe.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import inspect
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
from .websocket import InvalidJSON
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("django_socket")
|
|
35
|
+
|
|
36
|
+
CUALQUIERA = "*"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Events:
|
|
40
|
+
"""
|
|
41
|
+
Despacha mensajes JSON segun un campo (por defecto `"type"`).
|
|
42
|
+
|
|
43
|
+
`key` -- el campo que decide el tipo. Usa `Events(key="action")` si tu
|
|
44
|
+
protocolo se llama de otra forma.
|
|
45
|
+
`strict` -- que hacer con un tipo que nadie maneja. Por defecto se ignora
|
|
46
|
+
(y se anota en el log); con `strict=True` se cierra con 4400.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, key: str = "type", strict: bool = False):
|
|
50
|
+
self.key = key
|
|
51
|
+
self.strict = strict
|
|
52
|
+
self._handlers: dict[str, tuple] = {}
|
|
53
|
+
|
|
54
|
+
def on(self, *tipos: str):
|
|
55
|
+
"""
|
|
56
|
+
Registra un handler para uno o varios tipos.
|
|
57
|
+
|
|
58
|
+
@chat.on("mensaje")
|
|
59
|
+
@chat.on("entrar", "salir") # varios de golpe
|
|
60
|
+
@chat.on("*") # lo que no case con nada mas
|
|
61
|
+
|
|
62
|
+
El handler puede pedir los datos o no:
|
|
63
|
+
|
|
64
|
+
async def handler(sock, datos) -> datos = el mensaje sin el campo key
|
|
65
|
+
async def handler(sock) -> te basta con saber que llego
|
|
66
|
+
"""
|
|
67
|
+
if not tipos:
|
|
68
|
+
raise TypeError("@on() necesita al menos un tipo: @on('mensaje')")
|
|
69
|
+
|
|
70
|
+
def decorator(fn):
|
|
71
|
+
if not inspect.iscoroutinefunction(fn):
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"@on espera 'async def', y {fn.__name__} es una funcion "
|
|
74
|
+
f"normal."
|
|
75
|
+
)
|
|
76
|
+
quiere_datos = _quiere_datos(fn)
|
|
77
|
+
for tipo in tipos:
|
|
78
|
+
if tipo in self._handlers:
|
|
79
|
+
anterior = self._handlers[tipo][0].__name__
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"El tipo {tipo!r} ya lo maneja {anterior}."
|
|
82
|
+
)
|
|
83
|
+
self._handlers[tipo] = (fn, quiere_datos)
|
|
84
|
+
return fn
|
|
85
|
+
|
|
86
|
+
return decorator
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def tipos(self) -> list[str]:
|
|
90
|
+
return sorted(self._handlers)
|
|
91
|
+
|
|
92
|
+
async def run(self, sock) -> None:
|
|
93
|
+
"""Consume mensajes hasta que el cliente cierra."""
|
|
94
|
+
async for msg in sock:
|
|
95
|
+
await self.handle(sock, msg.json())
|
|
96
|
+
|
|
97
|
+
async def handle(self, sock, datos) -> None:
|
|
98
|
+
"""Despacha un mensaje ya parseado. Util para testear un handler suelto."""
|
|
99
|
+
if not isinstance(datos, dict):
|
|
100
|
+
raise InvalidJSON(datos, f"se esperaba un objeto con {self.key!r}")
|
|
101
|
+
|
|
102
|
+
tipo = datos.get(self.key)
|
|
103
|
+
entrada = self._handlers.get(tipo) or self._handlers.get(CUALQUIERA)
|
|
104
|
+
|
|
105
|
+
if entrada is None:
|
|
106
|
+
if self.strict:
|
|
107
|
+
raise InvalidJSON(
|
|
108
|
+
datos, f"tipo {tipo!r} desconocido; hay: {self.tipos}"
|
|
109
|
+
)
|
|
110
|
+
# A nivel WARNING a proposito: casi siempre es una errata en el
|
|
111
|
+
# nombre del tipo, y en DEBUG no la ve nadie. Si tu protocolo manda
|
|
112
|
+
# tipos que de verdad quieres ignorar, registra un @on("*") vacio.
|
|
113
|
+
logger.warning(
|
|
114
|
+
"django_socket: nadie maneja %s=%r en %s (registrados: %s)",
|
|
115
|
+
self.key, tipo, sock.path, self.tipos or "ninguno",
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
handler, quiere_datos = entrada
|
|
120
|
+
if quiere_datos:
|
|
121
|
+
await handler(sock, {k: v for k, v in datos.items() if k != self.key})
|
|
122
|
+
else:
|
|
123
|
+
await handler(sock)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _quiere_datos(fn) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Mira si el handler pide el payload ademas del socket.
|
|
129
|
+
|
|
130
|
+
Se resuelve una vez al registrar, no en cada mensaje.
|
|
131
|
+
"""
|
|
132
|
+
params = [
|
|
133
|
+
p for p in inspect.signature(fn).parameters.values()
|
|
134
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
135
|
+
]
|
|
136
|
+
if len(params) == 1:
|
|
137
|
+
return False
|
|
138
|
+
if len(params) == 2:
|
|
139
|
+
return True
|
|
140
|
+
raise TypeError(
|
|
141
|
+
f"{fn.__name__} debe aceptar (sock) o (sock, datos), no "
|
|
142
|
+
f"{len(params)} parametros posicionales."
|
|
143
|
+
)
|
django_socket/groups.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Capa de difusion: grupos y broadcast.
|
|
2
|
+
|
|
3
|
+
`MemoryLayer` sirve para un solo proceso (dev, o un unico worker).
|
|
4
|
+
`RedisLayer` reparte el fan-out entre procesos via pub/sub.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("django_socket")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseLayer:
|
|
18
|
+
async def startup(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
async def shutdown(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
async def add(self, group: str, sock) -> None:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
async def discard(self, group: str, sock) -> None:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
async def send(self, group: str, data: Any, *, exclude=None) -> None:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
async def size(self, group: str) -> int:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryLayer(BaseLayer):
|
|
36
|
+
"""Grupos en memoria del proceso. Sin dependencias externas."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._groups: dict[str, set] = {}
|
|
40
|
+
|
|
41
|
+
async def add(self, group: str, sock) -> None:
|
|
42
|
+
self._groups.setdefault(group, set()).add(sock)
|
|
43
|
+
|
|
44
|
+
async def discard(self, group: str, sock) -> None:
|
|
45
|
+
members = self._groups.get(group)
|
|
46
|
+
if members:
|
|
47
|
+
members.discard(sock)
|
|
48
|
+
if not members:
|
|
49
|
+
del self._groups[group]
|
|
50
|
+
|
|
51
|
+
async def size(self, group: str) -> int:
|
|
52
|
+
return len(self._groups.get(group, ()))
|
|
53
|
+
|
|
54
|
+
async def send(self, group: str, data: Any, *, exclude=None) -> None:
|
|
55
|
+
await self._deliver_local(group, data, exclude=exclude)
|
|
56
|
+
|
|
57
|
+
async def _deliver_local(self, group: str, data: Any, *, exclude=None) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Reparte encolando, sin esperar a que nadie lea.
|
|
60
|
+
|
|
61
|
+
Esperar seria el bug: un solo miembro que no consume dejaria colgado
|
|
62
|
+
para siempre al que difunde. Cada socket tiene un buzon acotado; si se
|
|
63
|
+
llena, ese cliente va demasiado atrasado y se le echa en vez de dejar
|
|
64
|
+
que arrastre a los demas.
|
|
65
|
+
"""
|
|
66
|
+
miembros = [s for s in self._groups.get(group, ()) if s is not exclude]
|
|
67
|
+
if not miembros:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
lentos = []
|
|
71
|
+
for sock in miembros:
|
|
72
|
+
if not await sock.enqueue(data):
|
|
73
|
+
lentos.append(sock)
|
|
74
|
+
|
|
75
|
+
# Cede el turno una vez por difusion, no una por miembro: es donde
|
|
76
|
+
# corren los escritores. Sin esto, un handler que difunde en bucle
|
|
77
|
+
# (`for fila in lote: await sock.broadcast(fila)`) no soltaria nunca el
|
|
78
|
+
# loop, los buzones se llenarian y acabaria echando a clientes sanos.
|
|
79
|
+
await asyncio.sleep(0)
|
|
80
|
+
|
|
81
|
+
for sock in lentos:
|
|
82
|
+
logger.warning(
|
|
83
|
+
"django_socket: %r no consume (buzon lleno); se le echa del "
|
|
84
|
+
"grupo %r. Sube DJANGO_SOCKET['SEND_QUEUE_MAX'] si tu caso "
|
|
85
|
+
"manda rafagas legitimas.",
|
|
86
|
+
sock, group,
|
|
87
|
+
)
|
|
88
|
+
sock.evict()
|
|
89
|
+
await self.discard(group, sock)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RedisLayer(MemoryLayer):
|
|
93
|
+
"""
|
|
94
|
+
Mantiene los miembros locales igual que MemoryLayer, pero publica cada
|
|
95
|
+
broadcast en Redis para que los demas procesos entreguen a los suyos.
|
|
96
|
+
|
|
97
|
+
Sobrevive a que Redis se caiga: la entrega local sigue funcionando, los
|
|
98
|
+
sockets de los usuarios no se cierran, y al volver Redis el proceso se
|
|
99
|
+
resuscribe solo. Lo que se publique mientras esta caido se pierde -- esto
|
|
100
|
+
es pub/sub, no una cola.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
ESPERA_MAX = 10.0 # tope del backoff al reconectar
|
|
104
|
+
LATIDO = 15.0 # health check de redis-py, en segundos
|
|
105
|
+
|
|
106
|
+
def __init__(self, url: str = "redis://localhost:6379/0", prefix: str = "djws"):
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.url = url
|
|
109
|
+
self.channel = f"{prefix}:broadcast"
|
|
110
|
+
self._redis = None
|
|
111
|
+
self._pubsub = None
|
|
112
|
+
self._listener: asyncio.Task | None = None
|
|
113
|
+
self._conectado = False
|
|
114
|
+
# Identifica a este proceso para no entregar dos veces lo que ya
|
|
115
|
+
# entregamos localmente.
|
|
116
|
+
self._origin = f"{id(self)}"
|
|
117
|
+
|
|
118
|
+
async def startup(self) -> None:
|
|
119
|
+
try:
|
|
120
|
+
import redis.asyncio as aioredis
|
|
121
|
+
except ImportError as exc: # pragma: no cover
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"RedisLayer necesita el paquete 'redis'. Instalalo con: pip install redis"
|
|
124
|
+
) from exc
|
|
125
|
+
self._redis = aioredis.from_url(
|
|
126
|
+
self.url,
|
|
127
|
+
health_check_interval=self.LATIDO, # sin esto no detecta la caida
|
|
128
|
+
retry_on_timeout=True,
|
|
129
|
+
)
|
|
130
|
+
self._pubsub = self._redis.pubsub()
|
|
131
|
+
await self._pubsub.subscribe(self.channel)
|
|
132
|
+
self._conectado = True
|
|
133
|
+
self._listener = asyncio.create_task(self._listen())
|
|
134
|
+
logger.info("django_socket: RedisLayer conectada a %s", self.url)
|
|
135
|
+
|
|
136
|
+
async def shutdown(self) -> None:
|
|
137
|
+
self._conectado = False
|
|
138
|
+
if self._listener:
|
|
139
|
+
self._listener.cancel()
|
|
140
|
+
try:
|
|
141
|
+
await self._listener
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
pass
|
|
144
|
+
if self._pubsub:
|
|
145
|
+
try:
|
|
146
|
+
await self._pubsub.unsubscribe(self.channel)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
await self._pubsub.aclose()
|
|
150
|
+
if self._redis:
|
|
151
|
+
await self._redis.aclose()
|
|
152
|
+
|
|
153
|
+
async def send(self, group: str, data: Any, *, exclude=None) -> None:
|
|
154
|
+
# Entrega local inmediata (asi `exclude` funciona por identidad)...
|
|
155
|
+
await self._deliver_local(group, data, exclude=exclude)
|
|
156
|
+
# ...y avisa al resto de procesos.
|
|
157
|
+
carga = json.dumps(
|
|
158
|
+
{"group": group, "data": data, "origin": self._origin}, default=str
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
await self._redis.publish(self.channel, carga)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
# Que Redis falle no puede tumbar la conexion del usuario: la
|
|
164
|
+
# entrega local ya se hizo y el handler debe seguir vivo. Se pierde
|
|
165
|
+
# el fan-out a los demas procesos, y por eso se loguea como error.
|
|
166
|
+
logger.error(
|
|
167
|
+
"django_socket: no se pudo publicar en Redis (%s: %s). "
|
|
168
|
+
"El grupo %r solo recibio la entrega local.",
|
|
169
|
+
type(exc).__name__, exc, group,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _listen(self) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Bucle de escucha resistente.
|
|
175
|
+
|
|
176
|
+
Se usa `get_message(timeout=...)` en vez de `listen()` para tener un
|
|
177
|
+
despertar periodico: ahi es donde redis-py corre su health check y
|
|
178
|
+
donde podemos detectar que la conexion se fue. Con `listen()` a secas
|
|
179
|
+
el proceso se queda sordo para siempre tras una caida.
|
|
180
|
+
"""
|
|
181
|
+
espera = 0.5
|
|
182
|
+
while True:
|
|
183
|
+
try:
|
|
184
|
+
mensaje = await self._pubsub.get_message(
|
|
185
|
+
ignore_subscribe_messages=True, timeout=1.0
|
|
186
|
+
)
|
|
187
|
+
espera = 0.5 # todo bien, resetea el backoff
|
|
188
|
+
if mensaje is not None and mensaje.get("type") == "message":
|
|
189
|
+
await self._entregar(mensaje)
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
raise
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
if not self._conectado:
|
|
194
|
+
return
|
|
195
|
+
logger.warning(
|
|
196
|
+
"django_socket: se perdio la conexion con Redis (%s). "
|
|
197
|
+
"Reintentando en %.1fs.", type(exc).__name__, espera,
|
|
198
|
+
)
|
|
199
|
+
await asyncio.sleep(espera)
|
|
200
|
+
espera = min(espera * 2, self.ESPERA_MAX)
|
|
201
|
+
await self._resuscribir()
|
|
202
|
+
|
|
203
|
+
async def _entregar(self, mensaje) -> None:
|
|
204
|
+
try:
|
|
205
|
+
payload = json.loads(mensaje["data"])
|
|
206
|
+
except (ValueError, KeyError, TypeError):
|
|
207
|
+
logger.warning("django_socket: mensaje ilegible en %s", self.channel)
|
|
208
|
+
return
|
|
209
|
+
if payload.get("origin") == self._origin:
|
|
210
|
+
return # ya lo entregamos localmente
|
|
211
|
+
await self._deliver_local(payload["group"], payload["data"])
|
|
212
|
+
|
|
213
|
+
async def _resuscribir(self) -> None:
|
|
214
|
+
"""Vuelve a suscribirse tras un corte. Si Redis sigue caido, lo dira el bucle."""
|
|
215
|
+
try:
|
|
216
|
+
await self._pubsub.aclose()
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
self._pubsub = self._redis.pubsub()
|
|
220
|
+
await self._pubsub.subscribe(self.channel)
|
|
221
|
+
logger.info("django_socket: resuscrito a %s", self.channel)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------- layer global
|
|
225
|
+
|
|
226
|
+
_layer: BaseLayer | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_layer() -> BaseLayer:
|
|
230
|
+
global _layer
|
|
231
|
+
if _layer is None:
|
|
232
|
+
_layer = _build_layer()
|
|
233
|
+
return _layer
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def set_layer(layer: BaseLayer) -> None:
|
|
237
|
+
global _layer
|
|
238
|
+
_layer = layer
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_layer() -> BaseLayer:
|
|
242
|
+
from django.conf import settings
|
|
243
|
+
|
|
244
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
245
|
+
backend = conf.get("LAYER", "memory")
|
|
246
|
+
if backend == "memory":
|
|
247
|
+
return MemoryLayer()
|
|
248
|
+
if backend == "redis":
|
|
249
|
+
return RedisLayer(
|
|
250
|
+
url=conf.get("REDIS_URL", "redis://localhost:6379/0"),
|
|
251
|
+
prefix=conf.get("PREFIX", "djws"),
|
|
252
|
+
)
|
|
253
|
+
if callable(backend):
|
|
254
|
+
return backend()
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"DJANGO_SOCKET['LAYER'] invalido: {backend!r}. Usa 'memory', 'redis' "
|
|
257
|
+
f"o un callable que devuelva una BaseLayer."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------- API de usuario
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def broadcast(data: Any, *, to: str) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Envia a todos los miembros de un grupo, desde fuera de un handler.
|
|
267
|
+
|
|
268
|
+
await broadcast({"aviso": "mantenimiento"}, to="room:1")
|
|
269
|
+
"""
|
|
270
|
+
await get_layer().send(to, data)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def group_size(group: str) -> int:
|
|
274
|
+
"""Cuantos sockets locales hay en el grupo."""
|
|
275
|
+
return await get_layer().size(group)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def broadcast_sync(data: Any, *, to: str) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Igual que `broadcast`, para vistas sincronas, señales o tareas Celery.
|
|
281
|
+
|
|
282
|
+
Con la capa 'memory' solo alcanza a los sockets del mismo proceso; para
|
|
283
|
+
llegar a todos los workers necesitas LAYER='redis'.
|
|
284
|
+
"""
|
|
285
|
+
from asgiref.sync import async_to_sync
|
|
286
|
+
|
|
287
|
+
async_to_sync(broadcast)(data, to=to)
|
|
File without changes
|
|
File without changes
|