Skip to content

Commit f135e04

Browse files
livepeer-tessalivepeer-robotMax Holland
authored
fix: capture WebSocket error messages before close (#552)
* fix: capture WebSocket error messages before close The close frame's reason field is unreliable through proxies (often arrives as code=1006, reason=None). Fix: Client-side (cloud_connection.py): - Capture 'error' type messages and store in _last_close_reason - Now when close event fires, we have the real error from the message Server-side (fal_app.py): - Add 100ms delay between sending error and closing connection - Helps ensure error message is flushed before close frame Signed-off-by: livepeer-robot <robot@livepeer.org> * fix: don't overwrite error message reason with close frame's None The close handler was overwriting _last_close_reason with the close frame's reason (usually None through proxies), losing the error message we captured earlier. Now: 1. Error message arrives → _last_close_reason = 'Access denied: ...' 2. Close event arrives → keep existing reason, don't overwrite with None 3. UI toast shows the real error instead of 'reason: None' Signed-off-by: livepeer-robot <robot@livepeer.org> * Dont show previous connection errors and verify userID against API Signed-off-by: Max Holland <max@livepeer.org> --------- Signed-off-by: livepeer-robot <robot@livepeer.org> Signed-off-by: Max Holland <max@livepeer.org> Co-authored-by: livepeer-robot <robot@livepeer.org> Co-authored-by: Max Holland <max@livepeer.org>
1 parent 9cdaca9 commit f135e04

3 files changed

Lines changed: 63 additions & 20 deletions

File tree

frontend/src/components/Header.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,33 @@ export function Header({
3636
// Track the last close code we've shown a toast for to avoid duplicates
3737
const lastNotifiedCloseCodeRef = useRef<number | null>(null);
3838

39+
// Only show "connection lost" after we've seen a successful connection this session
40+
const hasBeenConnectedRef = useRef(false);
41+
3942
// Track previous connection state to detect transitions for pipeline refresh
4043
const prevConnectedRef = useRef(false);
4144

4245
// Detect unexpected disconnection and show toast
4346
useEffect(() => {
47+
if (isConnected) {
48+
hasBeenConnectedRef.current = true;
49+
lastNotifiedCloseCodeRef.current = null;
50+
}
51+
4452
if (
53+
hasBeenConnectedRef.current &&
4554
lastCloseCode !== null &&
4655
lastCloseCode !== lastNotifiedCloseCodeRef.current
4756
) {
4857
console.warn(
4958
`[Header] Cloud WebSocket closed unexpectedly (code=${lastCloseCode}, reason=${lastCloseReason})`
5059
);
5160
toast.error("Cloud connection lost", {
52-
description: `WebSocket closed (code: ${lastCloseCode}${lastCloseReason ? `, reason: ${lastCloseReason}` : ""})`,
61+
description: `WebSocket closed ${lastCloseReason ? `(${lastCloseReason})` : ""}`,
5362
duration: 10000,
5463
});
5564
lastNotifiedCloseCodeRef.current = lastCloseCode;
5665
}
57-
58-
// Reset the notified close code when connected (so we can show it again if it disconnects later)
59-
if (isConnected) {
60-
lastNotifiedCloseCodeRef.current = null;
61-
}
6266
}, [lastCloseCode, lastCloseReason, isConnected]);
6367

6468
// Refresh pipelines when cloud connection status changes

src/scope/cloud/fal_app.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,30 @@ async def validate_user_access(user_id: str) -> tuple[bool, str]:
2929
3030
Returns (is_valid, reason) tuple.
3131
"""
32+
import urllib.error
33+
import urllib.request
34+
3235
if not user_id:
3336
return False, "No user ID provided"
34-
return True, "Access granted"
37+
38+
url = f"{os.getenv('DAYDREAM_API_BASE', 'https://api.daydream.live')}/v1/users/{user_id}"
39+
print(f"Validating user access for {user_id} via {url}")
40+
41+
def fetch_user():
42+
req = urllib.request.Request(url)
43+
with urllib.request.urlopen(req, timeout=10) as response:
44+
return json.loads(response.read().decode())
45+
46+
try:
47+
# Run synchronous urllib in thread pool to not block event loop
48+
await asyncio.get_event_loop().run_in_executor(None, fetch_user)
49+
return True, "Access granted"
50+
except urllib.error.HTTPError as e:
51+
if e.code == 404:
52+
return False, "User not found"
53+
return False, f"Failed to fetch user: {e.code}"
54+
except Exception as e:
55+
return False, f"Error validating user: {e}"
3556

3657

3758
class KafkaPublisher:
@@ -425,9 +446,9 @@ async def check_max_duration_exceeded() -> bool:
425446
)
426447
await safe_send_json(
427448
{
428-
"type": "closing",
429-
"reason": "max_duration",
430-
"elapsed_seconds": elapsed_seconds,
449+
"type": "error",
450+
"error": "Max duration exceeded",
451+
"code": "MAX_DURATION_EXCEEDED",
431452
}
432453
)
433454
return True
@@ -728,10 +749,12 @@ async def handle_message(payload: dict) -> dict | None:
728749
await safe_send_json(
729750
{
730751
"type": "error",
731-
"error": f"Access denied: {reason}",
752+
"error": "Access denied",
732753
"code": "ACCESS_DENIED",
733754
}
734755
)
756+
# Small delay to let error message reach client before close
757+
# (close frame often gets lost through proxies)
735758
await ws.close(code=4003, reason="Access denied")
736759
return None
737760

src/scope/server/cloud_connection.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -289,24 +289,30 @@ async def _receive_loop(self) -> None:
289289
# Get close code and reason from the WebSocket
290290
close_code = self.ws.close_code if self.ws else None
291291
# Try to get close reason from message data or ws object
292-
close_reason = None
292+
close_frame_reason = None
293293
if msg.data:
294-
close_reason = str(msg.data)
294+
close_frame_reason = str(msg.data)
295295
elif msg.extra:
296-
close_reason = str(msg.extra)
296+
close_frame_reason = str(msg.extra)
297297

298-
# Store for status reporting to frontend
298+
# Store close code
299299
self._last_close_code = close_code
300-
self._last_close_reason = close_reason
300+
# Only update reason if we don't already have one from an error message
301+
# (error messages sent before close are more reliable than close frame reason)
302+
if close_frame_reason and not self._last_close_reason:
303+
self._last_close_reason = close_frame_reason
304+
305+
# Use the captured reason (from error message or close frame)
306+
effective_reason = self._last_close_reason
301307

302308
logger.warning(
303-
f"Cloud WebSocket closed (code={close_code}, reason={close_reason})"
309+
f"Cloud WebSocket closed (code={close_code}, reason={effective_reason})"
304310
)
305311

306312
# Publish error if this was an unexpected close
307313
if not self._stop_event.is_set():
308314
self._publish_cloud_error(
309-
f"Cloud WebSocket closed unexpectedly (code={close_code}, reason={close_reason})",
315+
f"Cloud WebSocket closed unexpectedly (code={close_code}, reason={effective_reason})",
310316
"CloudWebSocketClosed",
311317
error_type="cloud_websocket_closed",
312318
extra_error_fields={"close_code": close_code},
@@ -332,16 +338,26 @@ async def _receive_loop(self) -> None:
332338

333339
async def _handle_message(self, data: dict) -> None:
334340
"""Handle incoming WebSocket message."""
335-
request_id = data.get("request_id")
341+
msg_type = data.get("type")
342+
343+
# Capture error messages (often sent right before connection close)
344+
# The close frame's reason field is unreliable through proxies, so we
345+
# store the error from the message to have it when the close event fires
346+
if msg_type == "error":
347+
error_msg = data.get("error", "Unknown error")
348+
error_code = data.get("code")
349+
logger.warning(f"Cloud error message: {error_msg} (code={error_code})")
350+
self._last_close_reason = error_msg
351+
return
336352

353+
request_id = data.get("request_id")
337354
if request_id and request_id in self._pending_requests:
338355
# This is a response to a pending request
339356
future = self._pending_requests.pop(request_id)
340357
if not future.done():
341358
future.set_result(data)
342359
else:
343360
# Unsolicited message (e.g., notifications)
344-
msg_type = data.get("type")
345361
logger.debug(f"Received unsolicited message: {msg_type}")
346362

347363
async def send_and_wait(

0 commit comments

Comments
 (0)