[{"data":1,"prerenderedAt":1615},["ShallowReactive",2],{"post-fr-websockets-fastapi":3},{"id":4,"title":5,"body":6,"date":1601,"description":17,"excerpt":1602,"extension":1603,"meta":1604,"navigation":116,"path":1605,"readTime":141,"seo":1606,"slug":1607,"stem":1608,"tags":1609,"__hash__":1614},"fr_blog/fr/blog/websockets-fastapi.md","WebSockets : état partagé, authentification et déconnexions",{"type":7,"value":8,"toc":1589},"minimark",[9,14,18,23,77,80,84,87,430,441,445,452,455,460,587,590,889,893,896,994,997,1001,1004,1142,1153,1157,1160,1262,1268,1297,1301,1306,1309,1506,1513,1571,1578,1582,1585],[10,11,13],"h1",{"id":12},"websockets-avec-fastapi-état-partagé-authentification-et-déconnexions-propres","WebSockets avec FastAPI : état partagé, authentification et déconnexions propres",[15,16,17],"p",{},"Le tutoriel officiel FastAPI sur les WebSockets tient en vingt lignes. C'est suffisant pour comprendre l'API, pas pour construire quelque chose de fiable. Voici ce que les exemples ne montrent pas : authentification, gestion d'état distribuée, broadcasting, et nettoyage des connexions mortes.",[19,20,22],"h2",{"id":21},"le-problème-avec-lexemple-basique","Le problème avec l'exemple basique",[24,25,30],"pre",{"className":26,"code":27,"language":28,"meta":29,"style":29},"language-python shiki shiki-themes github-dark github-light","# Ce qu'on voit dans tous les tutos\n@app.websocket(\"/ws\")\nasync def websocket_endpoint(websocket: WebSocket):\n    await websocket.accept()\n    while True:\n        data = await websocket.receive_text()\n        await websocket.send_text(f\"Message: {data}\")\n","python","",[31,32,33,41,47,53,59,65,71],"code",{"__ignoreMap":29},[34,35,38],"span",{"class":36,"line":37},"line",1,[34,39,40],{},"# Ce qu'on voit dans tous les tutos\n",[34,42,44],{"class":36,"line":43},2,[34,45,46],{},"@app.websocket(\"/ws\")\n",[34,48,50],{"class":36,"line":49},3,[34,51,52],{},"async def websocket_endpoint(websocket: WebSocket):\n",[34,54,56],{"class":36,"line":55},4,[34,57,58],{},"    await websocket.accept()\n",[34,60,62],{"class":36,"line":61},5,[34,63,64],{},"    while True:\n",[34,66,68],{"class":36,"line":67},6,[34,69,70],{},"        data = await websocket.receive_text()\n",[34,72,74],{"class":36,"line":73},7,[34,75,76],{},"        await websocket.send_text(f\"Message: {data}\")\n",[15,78,79],{},"Ce code a plusieurs problèmes en production : aucune authentification, aucune gestion des déconnexions inattendues, pas de broadcasting vers d'autres clients, et un état qui ne survit pas à un redémarrage du pod.",[19,81,83],{"id":82},"connectionmanager-gérer-plusieurs-clients","ConnectionManager : gérer plusieurs clients",[15,85,86],{},"La première étape est un manager qui maintient la liste des connexions actives :",[24,88,90],{"className":26,"code":89,"language":28,"meta":29,"style":29},"import asyncio\nfrom fastapi import WebSocket\nfrom typing import Any\nimport logging\n\nlogger = logging.getLogger(__name__)\n\nclass ConnectionManager:\n    def __init__(self):\n        # user_id -> liste de websockets (un user peut avoir plusieurs onglets)\n        self._connections: dict[str, list[WebSocket]] = {}\n        self._lock = asyncio.Lock()\n\n    async def connect(self, websocket: WebSocket, user_id: str) -> None:\n        await websocket.accept()\n        async with self._lock:\n            if user_id not in self._connections:\n                self._connections[user_id] = []\n            self._connections[user_id].append(websocket)\n        logger.info(f\"WebSocket connecté : user={user_id}, total={self.total_connections}\")\n\n    async def disconnect(self, websocket: WebSocket, user_id: str) -> None:\n        async with self._lock:\n            if user_id in self._connections:\n                self._connections[user_id] = [\n                    ws for ws in self._connections[user_id] if ws != websocket\n                ]\n                if not self._connections[user_id]:\n                    del self._connections[user_id]\n        logger.info(f\"WebSocket déconnecté : user={user_id}\")\n\n    async def send_to_user(self, user_id: str, message: dict) -> None:\n        \"\"\"Envoie un message à toutes les connexions d'un utilisateur.\"\"\"\n        connections = self._connections.get(user_id, [])\n        dead_connections = []\n\n        for websocket in connections:\n            try:\n                await websocket.send_json(message)\n            except Exception:\n                dead_connections.append(websocket)\n\n        # Nettoyer les connexions mortes\n        for ws in dead_connections:\n            await self.disconnect(ws, user_id)\n\n    async def broadcast(self, message: dict, exclude_user: str | None = None) -> None:\n        \"\"\"Diffuse un message à tous les utilisateurs connectés.\"\"\"\n        tasks = []\n        for user_id in list(self._connections.keys()):\n            if user_id != exclude_user:\n                tasks.append(self.send_to_user(user_id, message))\n        await asyncio.gather(*tasks, return_exceptions=True)\n\n    @property\n    def total_connections(self) -> int:\n        return sum(len(ws_list) for ws_list in self._connections.values())\n\nmanager = ConnectionManager()\n",[31,91,92,97,102,107,112,118,123,127,133,139,145,151,157,162,168,174,180,186,192,198,204,209,215,220,226,232,238,244,250,256,262,267,273,279,285,291,296,302,308,314,320,326,331,337,343,349,354,360,366,372,378,384,390,396,401,407,413,419,424],{"__ignoreMap":29},[34,93,94],{"class":36,"line":37},[34,95,96],{},"import asyncio\n",[34,98,99],{"class":36,"line":43},[34,100,101],{},"from fastapi import WebSocket\n",[34,103,104],{"class":36,"line":49},[34,105,106],{},"from typing import Any\n",[34,108,109],{"class":36,"line":55},[34,110,111],{},"import logging\n",[34,113,114],{"class":36,"line":61},[34,115,117],{"emptyLinePlaceholder":116},true,"\n",[34,119,120],{"class":36,"line":67},[34,121,122],{},"logger = logging.getLogger(__name__)\n",[34,124,125],{"class":36,"line":73},[34,126,117],{"emptyLinePlaceholder":116},[34,128,130],{"class":36,"line":129},8,[34,131,132],{},"class ConnectionManager:\n",[34,134,136],{"class":36,"line":135},9,[34,137,138],{},"    def __init__(self):\n",[34,140,142],{"class":36,"line":141},10,[34,143,144],{},"        # user_id -> liste de websockets (un user peut avoir plusieurs onglets)\n",[34,146,148],{"class":36,"line":147},11,[34,149,150],{},"        self._connections: dict[str, list[WebSocket]] = {}\n",[34,152,154],{"class":36,"line":153},12,[34,155,156],{},"        self._lock = asyncio.Lock()\n",[34,158,160],{"class":36,"line":159},13,[34,161,117],{"emptyLinePlaceholder":116},[34,163,165],{"class":36,"line":164},14,[34,166,167],{},"    async def connect(self, websocket: WebSocket, user_id: str) -> None:\n",[34,169,171],{"class":36,"line":170},15,[34,172,173],{},"        await websocket.accept()\n",[34,175,177],{"class":36,"line":176},16,[34,178,179],{},"        async with self._lock:\n",[34,181,183],{"class":36,"line":182},17,[34,184,185],{},"            if user_id not in self._connections:\n",[34,187,189],{"class":36,"line":188},18,[34,190,191],{},"                self._connections[user_id] = []\n",[34,193,195],{"class":36,"line":194},19,[34,196,197],{},"            self._connections[user_id].append(websocket)\n",[34,199,201],{"class":36,"line":200},20,[34,202,203],{},"        logger.info(f\"WebSocket connecté : user={user_id}, total={self.total_connections}\")\n",[34,205,207],{"class":36,"line":206},21,[34,208,117],{"emptyLinePlaceholder":116},[34,210,212],{"class":36,"line":211},22,[34,213,214],{},"    async def disconnect(self, websocket: WebSocket, user_id: str) -> None:\n",[34,216,218],{"class":36,"line":217},23,[34,219,179],{},[34,221,223],{"class":36,"line":222},24,[34,224,225],{},"            if user_id in self._connections:\n",[34,227,229],{"class":36,"line":228},25,[34,230,231],{},"                self._connections[user_id] = [\n",[34,233,235],{"class":36,"line":234},26,[34,236,237],{},"                    ws for ws in self._connections[user_id] if ws != websocket\n",[34,239,241],{"class":36,"line":240},27,[34,242,243],{},"                ]\n",[34,245,247],{"class":36,"line":246},28,[34,248,249],{},"                if not self._connections[user_id]:\n",[34,251,253],{"class":36,"line":252},29,[34,254,255],{},"                    del self._connections[user_id]\n",[34,257,259],{"class":36,"line":258},30,[34,260,261],{},"        logger.info(f\"WebSocket déconnecté : user={user_id}\")\n",[34,263,265],{"class":36,"line":264},31,[34,266,117],{"emptyLinePlaceholder":116},[34,268,270],{"class":36,"line":269},32,[34,271,272],{},"    async def send_to_user(self, user_id: str, message: dict) -> None:\n",[34,274,276],{"class":36,"line":275},33,[34,277,278],{},"        \"\"\"Envoie un message à toutes les connexions d'un utilisateur.\"\"\"\n",[34,280,282],{"class":36,"line":281},34,[34,283,284],{},"        connections = self._connections.get(user_id, [])\n",[34,286,288],{"class":36,"line":287},35,[34,289,290],{},"        dead_connections = []\n",[34,292,294],{"class":36,"line":293},36,[34,295,117],{"emptyLinePlaceholder":116},[34,297,299],{"class":36,"line":298},37,[34,300,301],{},"        for websocket in connections:\n",[34,303,305],{"class":36,"line":304},38,[34,306,307],{},"            try:\n",[34,309,311],{"class":36,"line":310},39,[34,312,313],{},"                await websocket.send_json(message)\n",[34,315,317],{"class":36,"line":316},40,[34,318,319],{},"            except Exception:\n",[34,321,323],{"class":36,"line":322},41,[34,324,325],{},"                dead_connections.append(websocket)\n",[34,327,329],{"class":36,"line":328},42,[34,330,117],{"emptyLinePlaceholder":116},[34,332,334],{"class":36,"line":333},43,[34,335,336],{},"        # Nettoyer les connexions mortes\n",[34,338,340],{"class":36,"line":339},44,[34,341,342],{},"        for ws in dead_connections:\n",[34,344,346],{"class":36,"line":345},45,[34,347,348],{},"            await self.disconnect(ws, user_id)\n",[34,350,352],{"class":36,"line":351},46,[34,353,117],{"emptyLinePlaceholder":116},[34,355,357],{"class":36,"line":356},47,[34,358,359],{},"    async def broadcast(self, message: dict, exclude_user: str | None = None) -> None:\n",[34,361,363],{"class":36,"line":362},48,[34,364,365],{},"        \"\"\"Diffuse un message à tous les utilisateurs connectés.\"\"\"\n",[34,367,369],{"class":36,"line":368},49,[34,370,371],{},"        tasks = []\n",[34,373,375],{"class":36,"line":374},50,[34,376,377],{},"        for user_id in list(self._connections.keys()):\n",[34,379,381],{"class":36,"line":380},51,[34,382,383],{},"            if user_id != exclude_user:\n",[34,385,387],{"class":36,"line":386},52,[34,388,389],{},"                tasks.append(self.send_to_user(user_id, message))\n",[34,391,393],{"class":36,"line":392},53,[34,394,395],{},"        await asyncio.gather(*tasks, return_exceptions=True)\n",[34,397,399],{"class":36,"line":398},54,[34,400,117],{"emptyLinePlaceholder":116},[34,402,404],{"class":36,"line":403},55,[34,405,406],{},"    @property\n",[34,408,410],{"class":36,"line":409},56,[34,411,412],{},"    def total_connections(self) -> int:\n",[34,414,416],{"class":36,"line":415},57,[34,417,418],{},"        return sum(len(ws_list) for ws_list in self._connections.values())\n",[34,420,422],{"class":36,"line":421},58,[34,423,117],{"emptyLinePlaceholder":116},[34,425,427],{"class":36,"line":426},59,[34,428,429],{},"manager = ConnectionManager()\n",[15,431,432,433,436,437,440],{},"Le ",[31,434,435],{},"asyncio.Lock()"," protège les modifications du dictionnaire — en Python, les opérations sur les dicts ne sont pas thread-safe dans un contexte async avec des coroutines concurrentes. Un user peut avoir plusieurs connexions simultanées (plusieurs onglets), ce que la structure ",[31,438,439],{},"dict[str, list[WebSocket]]"," gère nativement.",[19,442,444],{"id":443},"authentification-dune-connexion-websocket","Authentification d'une connexion WebSocket",[15,446,447,448,451],{},"C'est là que la plupart des implémentations butent. Les WebSockets ne supportent pas les headers HTTP personnalisés depuis le navigateur — impossible d'envoyer un ",[31,449,450],{},"Authorization: Bearer ..."," dans la handshake initiale via l'API browser standard.",[15,453,454],{},"Deux approches viables :",[456,457,459],"h3",{"id":458},"approche-1-token-dans-le-query-parameter","Approche 1 : token dans le query parameter",[24,461,463],{"className":26,"code":462,"language":28,"meta":29,"style":29},"from fastapi import WebSocket, WebSocketException, status, Depends, Query\nfrom app.core.security import verify_token\n\nasync def get_websocket_user(\n    websocket: WebSocket,\n    token: str = Query(...),\n) -> str:\n    \"\"\"Extrait et valide l'utilisateur depuis le token en query param.\"\"\"\n    payload = verify_token(token)\n    if payload is None:\n        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n    return payload[\"sub\"]\n\n@app.websocket(\"/ws/notifications\")\nasync def notifications_ws(\n    websocket: WebSocket,\n    user_id: str = Depends(get_websocket_user),\n):\n    await manager.connect(websocket, user_id)\n    try:\n        while True:\n            await websocket.receive_text()  # Maintenir la connexion ouverte\n    except Exception:\n        await manager.disconnect(websocket, user_id)\n",[31,464,465,470,475,479,484,489,494,499,504,509,514,519,524,529,533,538,543,547,552,557,562,567,572,577,582],{"__ignoreMap":29},[34,466,467],{"class":36,"line":37},[34,468,469],{},"from fastapi import WebSocket, WebSocketException, status, Depends, Query\n",[34,471,472],{"class":36,"line":43},[34,473,474],{},"from app.core.security import verify_token\n",[34,476,477],{"class":36,"line":49},[34,478,117],{"emptyLinePlaceholder":116},[34,480,481],{"class":36,"line":55},[34,482,483],{},"async def get_websocket_user(\n",[34,485,486],{"class":36,"line":61},[34,487,488],{},"    websocket: WebSocket,\n",[34,490,491],{"class":36,"line":67},[34,492,493],{},"    token: str = Query(...),\n",[34,495,496],{"class":36,"line":73},[34,497,498],{},") -> str:\n",[34,500,501],{"class":36,"line":129},[34,502,503],{},"    \"\"\"Extrait et valide l'utilisateur depuis le token en query param.\"\"\"\n",[34,505,506],{"class":36,"line":135},[34,507,508],{},"    payload = verify_token(token)\n",[34,510,511],{"class":36,"line":141},[34,512,513],{},"    if payload is None:\n",[34,515,516],{"class":36,"line":147},[34,517,518],{},"        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n",[34,520,521],{"class":36,"line":153},[34,522,523],{},"        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n",[34,525,526],{"class":36,"line":159},[34,527,528],{},"    return payload[\"sub\"]\n",[34,530,531],{"class":36,"line":164},[34,532,117],{"emptyLinePlaceholder":116},[34,534,535],{"class":36,"line":170},[34,536,537],{},"@app.websocket(\"/ws/notifications\")\n",[34,539,540],{"class":36,"line":176},[34,541,542],{},"async def notifications_ws(\n",[34,544,545],{"class":36,"line":182},[34,546,488],{},[34,548,549],{"class":36,"line":188},[34,550,551],{},"    user_id: str = Depends(get_websocket_user),\n",[34,553,554],{"class":36,"line":194},[34,555,556],{},"):\n",[34,558,559],{"class":36,"line":200},[34,560,561],{},"    await manager.connect(websocket, user_id)\n",[34,563,564],{"class":36,"line":206},[34,565,566],{},"    try:\n",[34,568,569],{"class":36,"line":211},[34,570,571],{},"        while True:\n",[34,573,574],{"class":36,"line":217},[34,575,576],{},"            await websocket.receive_text()  # Maintenir la connexion ouverte\n",[34,578,579],{"class":36,"line":222},[34,580,581],{},"    except Exception:\n",[34,583,584],{"class":36,"line":228},[34,585,586],{},"        await manager.disconnect(websocket, user_id)\n",[15,588,589],{},"Côté client Vue.js :",[24,591,595],{"className":592,"code":593,"language":594,"meta":29,"style":29},"language-typescript shiki shiki-themes github-dark github-light","// composables/useWebSocket.ts\nexport function useWebSocket() {\n  const token = useCookie(\"access_token\")\n  const ws = ref\u003CWebSocket | null>(null)\n\n  const connect = () => {\n    ws.value = new WebSocket(\n      `wss://api.monapp.fr/ws/notifications?token=${token.value}`,\n    )\n    ws.value.onmessage = (event) => {\n      const message = JSON.parse(event.data)\n      handleMessage(message)\n    }\n    ws.value.onclose = () => {\n      // Reconnexion automatique après 3 secondes\n      setTimeout(connect, 3000)\n    }\n  }\n\n  onMounted(connect)\n  onUnmounted(() => ws.value?.close())\n\n  return { ws }\n}\n","typescript",[31,596,597,603,620,645,677,681,699,716,736,741,765,786,794,799,814,819,832,836,841,845,853,872,876,884],{"__ignoreMap":29},[34,598,599],{"class":36,"line":37},[34,600,602],{"class":601},"sryI4","// composables/useWebSocket.ts\n",[34,604,605,609,612,616],{"class":36,"line":43},[34,606,608],{"class":607},"scx8i","export",[34,610,611],{"class":607}," function",[34,613,615],{"class":614},"s-Z4r"," useWebSocket",[34,617,619],{"class":618},"sQ3_J","() {\n",[34,621,622,625,629,632,635,638,642],{"class":36,"line":49},[34,623,624],{"class":607},"  const",[34,626,628],{"class":627},"s0DvM"," token",[34,630,631],{"class":607}," =",[34,633,634],{"class":614}," useCookie",[34,636,637],{"class":618},"(",[34,639,641],{"class":640},"sg6BJ","\"access_token\"",[34,643,644],{"class":618},")\n",[34,646,647,649,652,654,657,660,663,666,669,672,675],{"class":36,"line":55},[34,648,624],{"class":607},[34,650,651],{"class":627}," ws",[34,653,631],{"class":607},[34,655,656],{"class":614}," ref",[34,658,659],{"class":618},"\u003C",[34,661,662],{"class":614},"WebSocket",[34,664,665],{"class":607}," |",[34,667,668],{"class":627}," null",[34,670,671],{"class":618},">(",[34,673,674],{"class":627},"null",[34,676,644],{"class":618},[34,678,679],{"class":36,"line":61},[34,680,117],{"emptyLinePlaceholder":116},[34,682,683,685,688,690,693,696],{"class":36,"line":67},[34,684,624],{"class":607},[34,686,687],{"class":614}," connect",[34,689,631],{"class":607},[34,691,692],{"class":618}," () ",[34,694,695],{"class":607},"=>",[34,697,698],{"class":618}," {\n",[34,700,701,704,707,710,713],{"class":36,"line":73},[34,702,703],{"class":618},"    ws.value ",[34,705,706],{"class":607},"=",[34,708,709],{"class":607}," new",[34,711,712],{"class":614}," WebSocket",[34,714,715],{"class":618},"(\n",[34,717,718,721,724,727,730,733],{"class":36,"line":129},[34,719,720],{"class":640},"      `wss://api.monapp.fr/ws/notifications?token=${",[34,722,723],{"class":618},"token",[34,725,726],{"class":640},".",[34,728,729],{"class":618},"value",[34,731,732],{"class":640},"}`",[34,734,735],{"class":618},",\n",[34,737,738],{"class":36,"line":135},[34,739,740],{"class":618},"    )\n",[34,742,743,746,749,751,754,758,761,763],{"class":36,"line":141},[34,744,745],{"class":618},"    ws.value.",[34,747,748],{"class":614},"onmessage",[34,750,631],{"class":607},[34,752,753],{"class":618}," (",[34,755,757],{"class":756},"sFbx2","event",[34,759,760],{"class":618},") ",[34,762,695],{"class":607},[34,764,698],{"class":618},[34,766,767,770,773,775,778,780,783],{"class":36,"line":147},[34,768,769],{"class":607},"      const",[34,771,772],{"class":627}," message",[34,774,631],{"class":607},[34,776,777],{"class":627}," JSON",[34,779,726],{"class":618},[34,781,782],{"class":614},"parse",[34,784,785],{"class":618},"(event.data)\n",[34,787,788,791],{"class":36,"line":153},[34,789,790],{"class":614},"      handleMessage",[34,792,793],{"class":618},"(message)\n",[34,795,796],{"class":36,"line":159},[34,797,798],{"class":618},"    }\n",[34,800,801,803,806,808,810,812],{"class":36,"line":164},[34,802,745],{"class":618},[34,804,805],{"class":614},"onclose",[34,807,631],{"class":607},[34,809,692],{"class":618},[34,811,695],{"class":607},[34,813,698],{"class":618},[34,815,816],{"class":36,"line":170},[34,817,818],{"class":601},"      // Reconnexion automatique après 3 secondes\n",[34,820,821,824,827,830],{"class":36,"line":176},[34,822,823],{"class":614},"      setTimeout",[34,825,826],{"class":618},"(connect, ",[34,828,829],{"class":627},"3000",[34,831,644],{"class":618},[34,833,834],{"class":36,"line":182},[34,835,798],{"class":618},[34,837,838],{"class":36,"line":188},[34,839,840],{"class":618},"  }\n",[34,842,843],{"class":36,"line":194},[34,844,117],{"emptyLinePlaceholder":116},[34,846,847,850],{"class":36,"line":200},[34,848,849],{"class":614},"  onMounted",[34,851,852],{"class":618},"(connect)\n",[34,854,855,858,861,863,866,869],{"class":36,"line":206},[34,856,857],{"class":614},"  onUnmounted",[34,859,860],{"class":618},"(() ",[34,862,695],{"class":607},[34,864,865],{"class":618}," ws.value?.",[34,867,868],{"class":614},"close",[34,870,871],{"class":618},"())\n",[34,873,874],{"class":36,"line":211},[34,875,117],{"emptyLinePlaceholder":116},[34,877,878,881],{"class":36,"line":217},[34,879,880],{"class":607},"  return",[34,882,883],{"class":618}," { ws }\n",[34,885,886],{"class":36,"line":222},[34,887,888],{"class":618},"}\n",[456,890,892],{"id":891},"approche-2-cookie-de-session-plus-sécurisée","Approche 2 : cookie de session (plus sécurisée)",[15,894,895],{},"Si tu utilises le BFF pattern avec des cookies HttpOnly, la connexion WebSocket envoie automatiquement les cookies du domaine — c'est le comportement natif du navigateur :",[24,897,899],{"className":26,"code":898,"language":28,"meta":29,"style":29},"@app.websocket(\"/ws/notifications\")\nasync def notifications_ws(\n    websocket: WebSocket,\n    session: dict = Depends(get_websocket_session),\n):\n    user_id = session[\"user_id\"]\n    await manager.connect(websocket, user_id)\n    # ...\n\nasync def get_websocket_session(websocket: WebSocket) -> dict:\n    session_id = websocket.cookies.get(\"session_id\")\n    if not session_id:\n        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n\n    session = await session_manager.get_session_by_id(session_id)\n    if not session:\n        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n\n    return session\n",[31,900,901,905,909,913,918,922,927,931,936,940,945,950,955,959,963,967,972,977,981,985,989],{"__ignoreMap":29},[34,902,903],{"class":36,"line":37},[34,904,537],{},[34,906,907],{"class":36,"line":43},[34,908,542],{},[34,910,911],{"class":36,"line":49},[34,912,488],{},[34,914,915],{"class":36,"line":55},[34,916,917],{},"    session: dict = Depends(get_websocket_session),\n",[34,919,920],{"class":36,"line":61},[34,921,556],{},[34,923,924],{"class":36,"line":67},[34,925,926],{},"    user_id = session[\"user_id\"]\n",[34,928,929],{"class":36,"line":73},[34,930,561],{},[34,932,933],{"class":36,"line":129},[34,934,935],{},"    # ...\n",[34,937,938],{"class":36,"line":135},[34,939,117],{"emptyLinePlaceholder":116},[34,941,942],{"class":36,"line":141},[34,943,944],{},"async def get_websocket_session(websocket: WebSocket) -> dict:\n",[34,946,947],{"class":36,"line":147},[34,948,949],{},"    session_id = websocket.cookies.get(\"session_id\")\n",[34,951,952],{"class":36,"line":153},[34,953,954],{},"    if not session_id:\n",[34,956,957],{"class":36,"line":159},[34,958,518],{},[34,960,961],{"class":36,"line":164},[34,962,523],{},[34,964,965],{"class":36,"line":170},[34,966,117],{"emptyLinePlaceholder":116},[34,968,969],{"class":36,"line":176},[34,970,971],{},"    session = await session_manager.get_session_by_id(session_id)\n",[34,973,974],{"class":36,"line":182},[34,975,976],{},"    if not session:\n",[34,978,979],{"class":36,"line":188},[34,980,518],{},[34,982,983],{"class":36,"line":194},[34,984,523],{},[34,986,987],{"class":36,"line":200},[34,988,117],{"emptyLinePlaceholder":116},[34,990,991],{"class":36,"line":206},[34,992,993],{},"    return session\n",[15,995,996],{},"L'approche cookie est préférable sur un BFF — le token ne transite jamais en clair dans l'URL (ce qui apparaîtrait dans les logs serveur).",[19,998,1000],{"id":999},"gestion-propre-des-déconnexions-inattendues","Gestion propre des déconnexions inattendues",[15,1002,1003],{},"Une connexion WebSocket peut mourir de plusieurs façons : l'utilisateur ferme l'onglet, le réseau se coupe, le pod client redémarre. Il faut détecter et nettoyer ces cas :",[24,1005,1007],{"className":26,"code":1006,"language":28,"meta":29,"style":29},"@app.websocket(\"/ws/notifications\")\nasync def notifications_ws(\n    websocket: WebSocket,\n    user_id: str = Depends(get_websocket_user),\n):\n    await manager.connect(websocket, user_id)\n    try:\n        while True:\n            try:\n                # Timeout sur receive : détecte les connexions mortes\n                data = await asyncio.wait_for(\n                    websocket.receive_text(),\n                    timeout=30.0\n                )\n                # Gérer les messages entrants si nécessaire\n                await handle_client_message(user_id, data)\n\n            except asyncio.TimeoutError:\n                # Envoyer un ping pour vérifier que le client est vivant\n                try:\n                    await websocket.send_json({\"type\": \"ping\"})\n                except Exception:\n                    break  # Connexion morte, sortir de la boucle\n\n    except Exception as e:\n        logger.info(f\"WebSocket fermé pour user={user_id} : {type(e).__name__}\")\n    finally:\n        # Nettoyage garanti même en cas d'exception\n        await manager.disconnect(websocket, user_id)\n",[31,1008,1009,1013,1017,1021,1025,1029,1033,1037,1041,1045,1050,1055,1060,1065,1070,1075,1080,1084,1089,1094,1099,1104,1109,1114,1118,1123,1128,1133,1138],{"__ignoreMap":29},[34,1010,1011],{"class":36,"line":37},[34,1012,537],{},[34,1014,1015],{"class":36,"line":43},[34,1016,542],{},[34,1018,1019],{"class":36,"line":49},[34,1020,488],{},[34,1022,1023],{"class":36,"line":55},[34,1024,551],{},[34,1026,1027],{"class":36,"line":61},[34,1028,556],{},[34,1030,1031],{"class":36,"line":67},[34,1032,561],{},[34,1034,1035],{"class":36,"line":73},[34,1036,566],{},[34,1038,1039],{"class":36,"line":129},[34,1040,571],{},[34,1042,1043],{"class":36,"line":135},[34,1044,307],{},[34,1046,1047],{"class":36,"line":141},[34,1048,1049],{},"                # Timeout sur receive : détecte les connexions mortes\n",[34,1051,1052],{"class":36,"line":147},[34,1053,1054],{},"                data = await asyncio.wait_for(\n",[34,1056,1057],{"class":36,"line":153},[34,1058,1059],{},"                    websocket.receive_text(),\n",[34,1061,1062],{"class":36,"line":159},[34,1063,1064],{},"                    timeout=30.0\n",[34,1066,1067],{"class":36,"line":164},[34,1068,1069],{},"                )\n",[34,1071,1072],{"class":36,"line":170},[34,1073,1074],{},"                # Gérer les messages entrants si nécessaire\n",[34,1076,1077],{"class":36,"line":176},[34,1078,1079],{},"                await handle_client_message(user_id, data)\n",[34,1081,1082],{"class":36,"line":182},[34,1083,117],{"emptyLinePlaceholder":116},[34,1085,1086],{"class":36,"line":188},[34,1087,1088],{},"            except asyncio.TimeoutError:\n",[34,1090,1091],{"class":36,"line":194},[34,1092,1093],{},"                # Envoyer un ping pour vérifier que le client est vivant\n",[34,1095,1096],{"class":36,"line":200},[34,1097,1098],{},"                try:\n",[34,1100,1101],{"class":36,"line":206},[34,1102,1103],{},"                    await websocket.send_json({\"type\": \"ping\"})\n",[34,1105,1106],{"class":36,"line":211},[34,1107,1108],{},"                except Exception:\n",[34,1110,1111],{"class":36,"line":217},[34,1112,1113],{},"                    break  # Connexion morte, sortir de la boucle\n",[34,1115,1116],{"class":36,"line":222},[34,1117,117],{"emptyLinePlaceholder":116},[34,1119,1120],{"class":36,"line":228},[34,1121,1122],{},"    except Exception as e:\n",[34,1124,1125],{"class":36,"line":234},[34,1126,1127],{},"        logger.info(f\"WebSocket fermé pour user={user_id} : {type(e).__name__}\")\n",[34,1129,1130],{"class":36,"line":240},[34,1131,1132],{},"    finally:\n",[34,1134,1135],{"class":36,"line":246},[34,1136,1137],{},"        # Nettoyage garanti même en cas d'exception\n",[34,1139,1140],{"class":36,"line":252},[34,1141,586],{},[15,1143,1144,1145,1148,1149,1152],{},"Le pattern ",[31,1146,1147],{},"try/finally"," autour de la boucle principale garantit que ",[31,1150,1151],{},"disconnect"," est toujours appelé, quelle que soit la raison de la fermeture.",[19,1154,1156],{"id":1155},"broadcaster-des-événements-depuis-nimporte-où-dans-lapp","Broadcaster des événements depuis n'importe où dans l'app",[15,1158,1159],{},"Le cas d'usage réel : un traitement backend se termine et doit notifier les clients connectés en temps réel.",[24,1161,1163],{"className":26,"code":1162,"language":28,"meta":29,"style":29},"# Dans un service métier\nclass CertificateService:\n    def __init__(self, repo: CertificateRepository, ws_manager: ConnectionManager):\n        self.repo = repo\n        self.ws_manager = ws_manager\n\n    async def process_certificate(self, cert_id: str, user_id: str) -> Certificate:\n        certificate = await self.repo.process(cert_id)\n\n        # Notifier l'utilisateur en temps réel\n        await self.ws_manager.send_to_user(user_id, {\n            \"type\": \"certificate_processed\",\n            \"data\": {\n                \"id\": certificate.id,\n                \"status\": certificate.status,\n                \"volume\": certificate.volume,\n            }\n        })\n\n        return certificate\n",[31,1164,1165,1170,1175,1180,1185,1190,1194,1199,1204,1208,1213,1218,1223,1228,1233,1238,1243,1248,1253,1257],{"__ignoreMap":29},[34,1166,1167],{"class":36,"line":37},[34,1168,1169],{},"# Dans un service métier\n",[34,1171,1172],{"class":36,"line":43},[34,1173,1174],{},"class CertificateService:\n",[34,1176,1177],{"class":36,"line":49},[34,1178,1179],{},"    def __init__(self, repo: CertificateRepository, ws_manager: ConnectionManager):\n",[34,1181,1182],{"class":36,"line":55},[34,1183,1184],{},"        self.repo = repo\n",[34,1186,1187],{"class":36,"line":61},[34,1188,1189],{},"        self.ws_manager = ws_manager\n",[34,1191,1192],{"class":36,"line":67},[34,1193,117],{"emptyLinePlaceholder":116},[34,1195,1196],{"class":36,"line":73},[34,1197,1198],{},"    async def process_certificate(self, cert_id: str, user_id: str) -> Certificate:\n",[34,1200,1201],{"class":36,"line":129},[34,1202,1203],{},"        certificate = await self.repo.process(cert_id)\n",[34,1205,1206],{"class":36,"line":135},[34,1207,117],{"emptyLinePlaceholder":116},[34,1209,1210],{"class":36,"line":141},[34,1211,1212],{},"        # Notifier l'utilisateur en temps réel\n",[34,1214,1215],{"class":36,"line":147},[34,1216,1217],{},"        await self.ws_manager.send_to_user(user_id, {\n",[34,1219,1220],{"class":36,"line":153},[34,1221,1222],{},"            \"type\": \"certificate_processed\",\n",[34,1224,1225],{"class":36,"line":159},[34,1226,1227],{},"            \"data\": {\n",[34,1229,1230],{"class":36,"line":164},[34,1231,1232],{},"                \"id\": certificate.id,\n",[34,1234,1235],{"class":36,"line":170},[34,1236,1237],{},"                \"status\": certificate.status,\n",[34,1239,1240],{"class":36,"line":176},[34,1241,1242],{},"                \"volume\": certificate.volume,\n",[34,1244,1245],{"class":36,"line":182},[34,1246,1247],{},"            }\n",[34,1249,1250],{"class":36,"line":188},[34,1251,1252],{},"        })\n",[34,1254,1255],{"class":36,"line":194},[34,1256,117],{"emptyLinePlaceholder":116},[34,1258,1259],{"class":36,"line":200},[34,1260,1261],{},"        return certificate\n",[15,1263,432,1264,1267],{},[31,1265,1266],{},"ConnectionManager"," est injecté comme dépendance FastAPI — un singleton partagé sur tout le processus :",[24,1269,1271],{"className":26,"code":1270,"language":28,"meta":29,"style":29},"# app/api/dependencies.py\nfrom app.api.websockets import manager\n\ndef get_ws_manager() -> ConnectionManager:\n    return manager\n",[31,1272,1273,1278,1283,1287,1292],{"__ignoreMap":29},[34,1274,1275],{"class":36,"line":37},[34,1276,1277],{},"# app/api/dependencies.py\n",[34,1279,1280],{"class":36,"line":43},[34,1281,1282],{},"from app.api.websockets import manager\n",[34,1284,1285],{"class":36,"line":49},[34,1286,117],{"emptyLinePlaceholder":116},[34,1288,1289],{"class":36,"line":55},[34,1290,1291],{},"def get_ws_manager() -> ConnectionManager:\n",[34,1293,1294],{"class":36,"line":61},[34,1295,1296],{},"    return manager\n",[19,1298,1300],{"id":1299},"le-problème-multi-pods-état-distribué-avec-redis-pubsub","Le problème multi-pods : état distribué avec Redis Pub/Sub",[15,1302,432,1303,1305],{},[31,1304,1266],{}," tel qu'il est décrit ci-dessus a une limite critique : il est en mémoire. Sur un déploiement multi-pods OpenShift avec 3 replicas, chaque pod a son propre manager. Un événement traité par le pod A ne sera pas broadcasté aux clients connectés sur le pod B ou C.",[15,1307,1308],{},"La solution : Redis Pub/Sub comme bus d'événements inter-pods.",[24,1310,1312],{"className":26,"code":1311,"language":28,"meta":29,"style":29},"import redis.asyncio as aioredis\nimport json\nimport asyncio\n\nclass DistributedConnectionManager(ConnectionManager):\n    def __init__(self, redis: aioredis.Redis):\n        super().__init__()\n        self.redis = redis\n        self.channel = \"ws:broadcast\"\n\n    async def publish_to_user(self, user_id: str, message: dict) -> None:\n        \"\"\"Publie un événement sur Redis — tous les pods le reçoivent.\"\"\"\n        await self.redis.publish(\n            f\"ws:user:{user_id}\",\n            json.dumps(message)\n        )\n\n    async def publish_broadcast(self, message: dict) -> None:\n        \"\"\"Publie un broadcast sur Redis.\"\"\"\n        await self.redis.publish(self.channel, json.dumps(message))\n\n    async def start_subscriber(self) -> None:\n        \"\"\"À lancer au démarrage du pod — écoute les événements Redis.\"\"\"\n        pubsub = self.redis.pubsub()\n        await pubsub.psubscribe(\"ws:user:*\", self.channel)\n\n        async for message in pubsub.listen():\n            if message[\"type\"] != \"pmessage\" and message[\"type\"] != \"message\":\n                continue\n\n            channel = message[\"channel\"].decode()\n            data = json.loads(message[\"data\"])\n\n            if channel == self.channel:\n                # Broadcast local vers les clients de CE pod\n                await super().broadcast(data)\n            elif channel.startswith(\"ws:user:\"):\n                user_id = channel.split(\":\")[-1]\n                # Envoyer aux clients de CE pod pour cet user\n                await super().send_to_user(user_id, data)\n",[31,1313,1314,1319,1324,1328,1332,1337,1342,1347,1352,1357,1361,1366,1371,1376,1381,1386,1391,1395,1400,1405,1410,1414,1419,1424,1429,1434,1438,1443,1448,1453,1457,1462,1467,1471,1476,1481,1486,1491,1496,1501],{"__ignoreMap":29},[34,1315,1316],{"class":36,"line":37},[34,1317,1318],{},"import redis.asyncio as aioredis\n",[34,1320,1321],{"class":36,"line":43},[34,1322,1323],{},"import json\n",[34,1325,1326],{"class":36,"line":49},[34,1327,96],{},[34,1329,1330],{"class":36,"line":55},[34,1331,117],{"emptyLinePlaceholder":116},[34,1333,1334],{"class":36,"line":61},[34,1335,1336],{},"class DistributedConnectionManager(ConnectionManager):\n",[34,1338,1339],{"class":36,"line":67},[34,1340,1341],{},"    def __init__(self, redis: aioredis.Redis):\n",[34,1343,1344],{"class":36,"line":73},[34,1345,1346],{},"        super().__init__()\n",[34,1348,1349],{"class":36,"line":129},[34,1350,1351],{},"        self.redis = redis\n",[34,1353,1354],{"class":36,"line":135},[34,1355,1356],{},"        self.channel = \"ws:broadcast\"\n",[34,1358,1359],{"class":36,"line":141},[34,1360,117],{"emptyLinePlaceholder":116},[34,1362,1363],{"class":36,"line":147},[34,1364,1365],{},"    async def publish_to_user(self, user_id: str, message: dict) -> None:\n",[34,1367,1368],{"class":36,"line":153},[34,1369,1370],{},"        \"\"\"Publie un événement sur Redis — tous les pods le reçoivent.\"\"\"\n",[34,1372,1373],{"class":36,"line":159},[34,1374,1375],{},"        await self.redis.publish(\n",[34,1377,1378],{"class":36,"line":164},[34,1379,1380],{},"            f\"ws:user:{user_id}\",\n",[34,1382,1383],{"class":36,"line":170},[34,1384,1385],{},"            json.dumps(message)\n",[34,1387,1388],{"class":36,"line":176},[34,1389,1390],{},"        )\n",[34,1392,1393],{"class":36,"line":182},[34,1394,117],{"emptyLinePlaceholder":116},[34,1396,1397],{"class":36,"line":188},[34,1398,1399],{},"    async def publish_broadcast(self, message: dict) -> None:\n",[34,1401,1402],{"class":36,"line":194},[34,1403,1404],{},"        \"\"\"Publie un broadcast sur Redis.\"\"\"\n",[34,1406,1407],{"class":36,"line":200},[34,1408,1409],{},"        await self.redis.publish(self.channel, json.dumps(message))\n",[34,1411,1412],{"class":36,"line":206},[34,1413,117],{"emptyLinePlaceholder":116},[34,1415,1416],{"class":36,"line":211},[34,1417,1418],{},"    async def start_subscriber(self) -> None:\n",[34,1420,1421],{"class":36,"line":217},[34,1422,1423],{},"        \"\"\"À lancer au démarrage du pod — écoute les événements Redis.\"\"\"\n",[34,1425,1426],{"class":36,"line":222},[34,1427,1428],{},"        pubsub = self.redis.pubsub()\n",[34,1430,1431],{"class":36,"line":228},[34,1432,1433],{},"        await pubsub.psubscribe(\"ws:user:*\", self.channel)\n",[34,1435,1436],{"class":36,"line":234},[34,1437,117],{"emptyLinePlaceholder":116},[34,1439,1440],{"class":36,"line":240},[34,1441,1442],{},"        async for message in pubsub.listen():\n",[34,1444,1445],{"class":36,"line":246},[34,1446,1447],{},"            if message[\"type\"] != \"pmessage\" and message[\"type\"] != \"message\":\n",[34,1449,1450],{"class":36,"line":252},[34,1451,1452],{},"                continue\n",[34,1454,1455],{"class":36,"line":258},[34,1456,117],{"emptyLinePlaceholder":116},[34,1458,1459],{"class":36,"line":264},[34,1460,1461],{},"            channel = message[\"channel\"].decode()\n",[34,1463,1464],{"class":36,"line":269},[34,1465,1466],{},"            data = json.loads(message[\"data\"])\n",[34,1468,1469],{"class":36,"line":275},[34,1470,117],{"emptyLinePlaceholder":116},[34,1472,1473],{"class":36,"line":281},[34,1474,1475],{},"            if channel == self.channel:\n",[34,1477,1478],{"class":36,"line":287},[34,1479,1480],{},"                # Broadcast local vers les clients de CE pod\n",[34,1482,1483],{"class":36,"line":293},[34,1484,1485],{},"                await super().broadcast(data)\n",[34,1487,1488],{"class":36,"line":298},[34,1489,1490],{},"            elif channel.startswith(\"ws:user:\"):\n",[34,1492,1493],{"class":36,"line":304},[34,1494,1495],{},"                user_id = channel.split(\":\")[-1]\n",[34,1497,1498],{"class":36,"line":310},[34,1499,1500],{},"                # Envoyer aux clients de CE pod pour cet user\n",[34,1502,1503],{"class":36,"line":316},[34,1504,1505],{},"                await super().send_to_user(user_id, data)\n",[15,1507,1508,1509,1512],{},"Démarrage du subscriber dans le ",[31,1510,1511],{},"lifespan"," FastAPI :",[24,1514,1516],{"className":26,"code":1515,"language":28,"meta":29,"style":29},"@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    # Démarrer l'écoute Redis en arrière-plan\n    task = asyncio.create_task(distributed_manager.start_subscriber())\n    background_tasks.add(task)\n    task.add_done_callback(background_tasks.discard)\n\n    yield\n\n    task.cancel()\n    await asyncio.gather(task, return_exceptions=True)\n",[31,1517,1518,1523,1528,1533,1538,1543,1548,1552,1557,1561,1566],{"__ignoreMap":29},[34,1519,1520],{"class":36,"line":37},[34,1521,1522],{},"@asynccontextmanager\n",[34,1524,1525],{"class":36,"line":43},[34,1526,1527],{},"async def lifespan(app: FastAPI):\n",[34,1529,1530],{"class":36,"line":49},[34,1531,1532],{},"    # Démarrer l'écoute Redis en arrière-plan\n",[34,1534,1535],{"class":36,"line":55},[34,1536,1537],{},"    task = asyncio.create_task(distributed_manager.start_subscriber())\n",[34,1539,1540],{"class":36,"line":61},[34,1541,1542],{},"    background_tasks.add(task)\n",[34,1544,1545],{"class":36,"line":67},[34,1546,1547],{},"    task.add_done_callback(background_tasks.discard)\n",[34,1549,1550],{"class":36,"line":73},[34,1551,117],{"emptyLinePlaceholder":116},[34,1553,1554],{"class":36,"line":129},[34,1555,1556],{},"    yield\n",[34,1558,1559],{"class":36,"line":135},[34,1560,117],{"emptyLinePlaceholder":116},[34,1562,1563],{"class":36,"line":141},[34,1564,1565],{},"    task.cancel()\n",[34,1567,1568],{"class":36,"line":147},[34,1569,1570],{},"    await asyncio.gather(task, return_exceptions=True)\n",[15,1572,1573,1574,1577],{},"Avec cette architecture, un service sur le pod A appelle ",[31,1575,1576],{},"publish_to_user()"," — Redis propage l'événement à tous les pods, et chaque pod le délivre localement aux clients concernés.",[19,1579,1581],{"id":1580},"ce-quil-faut-retenir","Ce qu'il faut retenir",[15,1583,1584],{},"Les WebSockets en production nécessitent de résoudre quatre problèmes distincts : l'authentification (cookie ou query param selon l'architecture), la gestion des connexions mortes (ping/timeout + try/finally), le broadcasting vers plusieurs clients d'un même user (liste de WebSockets par user_id), et la distribution multi-pods (Redis Pub/Sub). Chacun de ces problèmes est trivial pris séparément — c'est leur combinaison qui fait la solidité d'une implémentation production.",[1586,1587,1588],"style",{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sryI4, html code.shiki .sryI4{--shiki-dark:#6A737D;--shiki-default:#6A737D}html pre.shiki code .scx8i, html code.shiki .scx8i{--shiki-dark:#F97583;--shiki-default:#D73A49}html pre.shiki code .s-Z4r, html code.shiki .s-Z4r{--shiki-dark:#B392F0;--shiki-default:#6F42C1}html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .s0DvM, html code.shiki .s0DvM{--shiki-dark:#79B8FF;--shiki-default:#005CC5}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}html pre.shiki code .sFbx2, html code.shiki .sFbx2{--shiki-dark:#FFAB70;--shiki-default:#E36209}",{"title":29,"searchDepth":43,"depth":43,"links":1590},[1591,1592,1593,1597,1598,1599,1600],{"id":21,"depth":43,"text":22},{"id":82,"depth":43,"text":83},{"id":443,"depth":43,"text":444,"children":1594},[1595,1596],{"id":458,"depth":49,"text":459},{"id":891,"depth":49,"text":892},{"id":999,"depth":43,"text":1000},{"id":1155,"depth":43,"text":1156},{"id":1299,"depth":43,"text":1300},{"id":1580,"depth":43,"text":1581},"2025-03-13",null,"md",{},"/fr/blog/websockets-fastapi",{"title":5,"description":17},"websockets-fastapi","fr/blog/websockets-fastapi",[1610,1611,1612,1613],"FastAPI","WebSockets","Python","Temps réel","JtVqgZPvZrm1LPTy8zmXKr6dsXH27QIXjLUTnpLMt7A",1774645635854]