Skip to content

Commit 04e00a0

Browse files
genfleetgenfleet
authored andcommitted
fix: tolerate redis outages in realtime websockets
1 parent 5418601 commit 04e00a0

2 files changed

Lines changed: 111 additions & 45 deletions

File tree

backend/app/services/realtime_runtime/router.py

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -42,31 +42,37 @@ async def register_connection(
4242
user_id: str | None,
4343
) -> str:
4444
connection_id = uuid.uuid4().hex
45-
redis = await get_redis()
45+
setattr(websocket.state, "realtime_connection_id", connection_id)
4646
payload = {
4747
"agent_id": agent_id,
4848
"session_id": session_id or "",
4949
"user_id": user_id or "",
5050
"instance_id": self.instance_id,
5151
}
52-
async with redis.pipeline(transaction=True) as pipe:
53-
pipe.sadd(self._agent_index_key(agent_id), connection_id)
54-
pipe.hset(self._connection_key(connection_id), mapping=payload)
55-
pipe.expire(self._connection_key(connection_id), PRESENCE_TTL_SECONDS)
56-
pipe.expire(self._agent_index_key(agent_id), PRESENCE_TTL_SECONDS)
57-
await pipe.execute()
58-
setattr(websocket.state, "realtime_connection_id", connection_id)
52+
try:
53+
redis = await get_redis()
54+
async with redis.pipeline(transaction=True) as pipe:
55+
pipe.sadd(self._agent_index_key(agent_id), connection_id)
56+
pipe.hset(self._connection_key(connection_id), mapping=payload)
57+
pipe.expire(self._connection_key(connection_id), PRESENCE_TTL_SECONDS)
58+
pipe.expire(self._agent_index_key(agent_id), PRESENCE_TTL_SECONDS)
59+
await pipe.execute()
60+
except Exception as exc:
61+
logger.warning(f"[Realtime] Redis presence unavailable; using local websocket only: {exc}")
5962
return connection_id
6063

6164
async def unregister_connection(self, *, agent_id: str, websocket: WebSocket) -> None:
6265
connection_id = getattr(websocket.state, "realtime_connection_id", None)
6366
if not connection_id:
6467
return
65-
redis = await get_redis()
66-
async with redis.pipeline(transaction=True) as pipe:
67-
pipe.srem(self._agent_index_key(agent_id), connection_id)
68-
pipe.delete(self._connection_key(connection_id))
69-
await pipe.execute()
68+
try:
69+
redis = await get_redis()
70+
async with redis.pipeline(transaction=True) as pipe:
71+
pipe.srem(self._agent_index_key(agent_id), connection_id)
72+
pipe.delete(self._connection_key(connection_id))
73+
await pipe.execute()
74+
except Exception as exc:
75+
logger.warning(f"[Realtime] Redis presence cleanup failed: {exc}")
7076

7177
async def is_user_viewing_session(self, *, agent_id: str, session_id: str, user_id: str) -> bool:
7278
for record in await self._list_presence(agent_id):
@@ -118,21 +124,25 @@ async def route_message(
118124
if not remote_targets:
119125
return
120126

121-
redis = await get_redis()
122-
envelope = json.dumps(
123-
{
124-
"message": message,
125-
"agent_id": agent_id,
126-
"session_id": session_id,
127-
"user_id": user_id,
128-
"origin_instance_id": self.instance_id,
129-
}
130-
)
131-
publish_tasks = [
132-
redis.publish(f"{PUBSUB_PREFIX}:instance:{instance_id}", envelope)
133-
for instance_id in remote_targets
134-
]
135-
await asyncio.gather(*publish_tasks, return_exceptions=True)
127+
try:
128+
redis = await get_redis()
129+
envelope = json.dumps(
130+
{
131+
"message": message,
132+
"agent_id": agent_id,
133+
"session_id": session_id,
134+
"user_id": user_id,
135+
"origin_instance_id": self.instance_id,
136+
}
137+
)
138+
publish_tasks = [
139+
redis.publish(f"{PUBSUB_PREFIX}:instance:{instance_id}", envelope)
140+
for instance_id in remote_targets
141+
]
142+
await asyncio.gather(*publish_tasks, return_exceptions=True)
143+
except Exception as exc:
144+
logger.warning(f"[Realtime] Redis pubsub unavailable; skipped remote websocket routing: {exc}")
145+
return
136146
logger.debug(
137147
f"[Realtime] Routed agent={agent_id} local={local_sent} remote_instances={list(remote_targets.keys())}"
138148
)
@@ -154,9 +164,13 @@ async def stop(self) -> None:
154164
self._started = False
155165

156166
async def _subscriber_loop(self, deliver_local) -> None:
157-
redis = await get_redis()
158-
pubsub = redis.pubsub()
159-
await pubsub.subscribe(self._instance_channel())
167+
try:
168+
redis = await get_redis()
169+
pubsub = redis.pubsub()
170+
await pubsub.subscribe(self._instance_channel())
171+
except Exception as exc:
172+
logger.warning(f"[Realtime] Redis pubsub subscriber unavailable; remote websocket routing disabled: {exc}")
173+
return
160174
try:
161175
while True:
162176
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
@@ -180,21 +194,25 @@ async def _subscriber_loop(self, deliver_local) -> None:
180194
await pubsub.aclose()
181195

182196
async def _list_presence(self, agent_id: str) -> list[dict[str, str]]:
183-
redis = await get_redis()
184-
connection_ids = await redis.smembers(self._agent_index_key(agent_id))
185-
if not connection_ids:
197+
try:
198+
redis = await get_redis()
199+
connection_ids = await redis.smembers(self._agent_index_key(agent_id))
200+
if not connection_ids:
201+
return []
202+
records: list[dict[str, str]] = []
203+
stale_ids: list[str] = []
204+
for connection_id in connection_ids:
205+
data = await redis.hgetall(self._connection_key(connection_id))
206+
if not data:
207+
stale_ids.append(connection_id)
208+
continue
209+
records.append(data)
210+
if stale_ids:
211+
await redis.srem(self._agent_index_key(agent_id), *stale_ids)
212+
return records
213+
except Exception as exc:
214+
logger.warning(f"[Realtime] Redis presence lookup failed: {exc}")
186215
return []
187-
records: list[dict[str, str]] = []
188-
stale_ids: list[str] = []
189-
for connection_id in connection_ids:
190-
data = await redis.hgetall(self._connection_key(connection_id))
191-
if not data:
192-
stale_ids.append(connection_id)
193-
continue
194-
records.append(data)
195-
if stale_ids:
196-
await redis.srem(self._agent_index_key(agent_id), *stale_ids)
197-
return records
198216

199217

200218
realtime_router = RealtimeRouter()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from types import SimpleNamespace
2+
3+
import pytest
4+
5+
from app.services.realtime_runtime import router as realtime_router_module
6+
from app.services.realtime_runtime.router import RealtimeRouter
7+
8+
9+
class DummyWebSocket:
10+
def __init__(self) -> None:
11+
self.state = SimpleNamespace()
12+
self.sent: list[dict] = []
13+
14+
async def send_json(self, message: dict) -> None:
15+
self.sent.append(message)
16+
17+
18+
@pytest.mark.asyncio
19+
async def test_realtime_router_falls_back_to_local_connections_when_redis_unavailable(monkeypatch):
20+
async def unavailable_redis():
21+
raise ConnectionError("redis is down")
22+
23+
monkeypatch.setattr(realtime_router_module, "get_redis", unavailable_redis)
24+
25+
router = RealtimeRouter()
26+
websocket = DummyWebSocket()
27+
28+
connection_id = await router.register_connection(
29+
agent_id="agent-1",
30+
websocket=websocket,
31+
session_id="session-1",
32+
user_id="user-1",
33+
)
34+
35+
assert connection_id
36+
assert websocket.state.realtime_connection_id == connection_id
37+
38+
await router.route_message(
39+
agent_id="agent-1",
40+
message={"type": "chunk", "content": "hello"},
41+
local_connections=[(websocket, "session-1", "user-1")],
42+
session_id="session-1",
43+
user_id="user-1",
44+
)
45+
46+
assert websocket.sent == [{"type": "chunk", "content": "hello"}]
47+
48+
await router.unregister_connection(agent_id="agent-1", websocket=websocket)

0 commit comments

Comments
 (0)