Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/client_ws.py: 27%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

340 statements  

1"""WebSocket client for asyncio.""" 

2 

3import asyncio 

4import sys 

5from asyncio.base_events import BaseEventLoop 

6from collections.abc import Callable 

7from types import TracebackType 

8from typing import Any, Final, Generic, Literal, overload 

9 

10from ._websocket.reader import WebSocketDataQueue, WebSocketReader 

11from .client_exceptions import ClientError, ServerTimeoutError, WSMessageTypeError 

12from .client_reqrep import ClientResponse 

13from .helpers import calculate_timeout_when, frozen_dataclass_decorator, set_result 

14from .http import ( 

15 WS_CLOSED_MESSAGE, 

16 WS_CLOSING_MESSAGE, 

17 WebSocketError, 

18 WSCloseCode, 

19 WSMessageDecodeText, 

20 WSMessageNoDecodeText, 

21 WSMsgType, 

22) 

23from .http_websocket import _INTERNAL_RECEIVE_TYPES, WebSocketWriter, WSMessageError 

24from .streams import EofStream 

25from .typedefs import ( 

26 DEFAULT_JSON_DECODER, 

27 DEFAULT_JSON_ENCODER, 

28 JSONBytesEncoder, 

29 JSONDecoder, 

30 JSONEncoder, 

31) 

32 

33if sys.version_info >= (3, 13): 

34 from typing import TypeVar 

35else: 

36 from typing_extensions import TypeVar 

37 

38if sys.version_info >= (3, 11): 

39 import asyncio as async_timeout 

40 from typing import Self 

41else: 

42 import async_timeout 

43 from typing_extensions import Self 

44 

45# TypeVar for whether text messages are decoded to str (True) or kept as bytes (False) 

46# Covariant because it only affects return types, not input types 

47_DecodeText = TypeVar("_DecodeText", bound=bool, covariant=True, default=Literal[True]) 

48 

49 

50@frozen_dataclass_decorator 

51class ClientWSTimeout: 

52 ws_receive: float | None = None 

53 ws_close: float | None = None 

54 

55 

56DEFAULT_WS_CLIENT_TIMEOUT: Final[ClientWSTimeout] = ClientWSTimeout( 

57 ws_receive=None, ws_close=10.0 

58) 

59 

60 

61class ClientWebSocketResponse(Generic[_DecodeText]): 

62 def __init__( 

63 self, 

64 reader: WebSocketDataQueue, 

65 writer: WebSocketWriter, 

66 protocol: str | None, 

67 response: ClientResponse, 

68 timeout: ClientWSTimeout, 

69 autoclose: bool, 

70 autoping: bool, 

71 loop: asyncio.AbstractEventLoop, 

72 *, 

73 heartbeat: float | None = None, 

74 compress: int = 0, 

75 client_notakeover: bool = False, 

76 ) -> None: 

77 self._response = response 

78 self._conn = response.connection 

79 

80 self._writer = writer 

81 self._reader = reader 

82 # Set by ClientSession._ws_connect; owns the parser so a stalled 

83 # reader parked on the queue by weakref stays alive while this 

84 # response can still be drained. 

85 self._parser: WebSocketReader | None = None 

86 self._protocol = protocol 

87 self._closed = False 

88 self._closing = False 

89 self._close_code: int | None = None 

90 self._timeout = timeout 

91 self._autoclose = autoclose 

92 self._autoping = autoping 

93 self._heartbeat = heartbeat 

94 self._heartbeat_cb: asyncio.TimerHandle | None = None 

95 self._heartbeat_when: float = 0.0 

96 if heartbeat is not None: 

97 self._pong_heartbeat = heartbeat / 2.0 

98 self._pong_response_cb: asyncio.TimerHandle | None = None 

99 self._loop = loop 

100 self._waiting: bool = False 

101 self._close_wait: asyncio.Future[None] | None = None 

102 self._exception: BaseException | None = None 

103 self._compress = compress 

104 self._client_notakeover = client_notakeover 

105 self._ping_task: asyncio.Task[None] | None = None 

106 self._need_heartbeat_reset = False 

107 self._heartbeat_reset_handle: asyncio.Handle | None = None 

108 

109 self._reset_heartbeat() 

110 

111 def _cancel_heartbeat(self) -> None: 

112 self._cancel_pong_response_cb() 

113 if self._heartbeat_reset_handle is not None: 

114 self._heartbeat_reset_handle.cancel() 

115 self._heartbeat_reset_handle = None 

116 self._need_heartbeat_reset = False 

117 if self._heartbeat_cb is not None: 

118 self._heartbeat_cb.cancel() 

119 self._heartbeat_cb = None 

120 if self._ping_task is not None: 

121 self._ping_task.cancel() 

122 self._ping_task = None 

123 

124 def _cancel_pong_response_cb(self) -> None: 

125 if self._pong_response_cb is not None: 

126 self._pong_response_cb.cancel() 

127 self._pong_response_cb = None 

128 

129 def _on_data_received(self) -> None: 

130 if self._heartbeat is None or self._need_heartbeat_reset: 

131 return 

132 loop = self._loop 

133 assert loop is not None 

134 # Coalesce multiple chunks received in the same loop tick into a single 

135 # heartbeat reset. Resetting immediately per chunk increases timer churn. 

136 self._need_heartbeat_reset = True 

137 self._heartbeat_reset_handle = loop.call_soon(self._flush_heartbeat_reset) 

138 

139 def _flush_heartbeat_reset(self) -> None: 

140 self._heartbeat_reset_handle = None 

141 if not self._need_heartbeat_reset: 

142 return 

143 self._reset_heartbeat() 

144 self._need_heartbeat_reset = False 

145 

146 def _reset_heartbeat(self) -> None: 

147 if self._heartbeat is None: 

148 return 

149 self._cancel_pong_response_cb() 

150 loop = self._loop 

151 assert loop is not None 

152 conn = self._conn 

153 timeout_ceil_threshold = ( 

154 conn._connector._timeout_ceil_threshold if conn is not None else 5 

155 ) 

156 now = loop.time() 

157 when = calculate_timeout_when(now, self._heartbeat, timeout_ceil_threshold) 

158 self._heartbeat_when = when 

159 if self._heartbeat_cb is None: 

160 # We do not cancel the previous heartbeat_cb here because 

161 # it generates a significant amount of TimerHandle churn 

162 # which causes asyncio to rebuild the heap frequently. 

163 # Instead _send_heartbeat() will reschedule the next 

164 # heartbeat if it fires too early. 

165 self._heartbeat_cb = loop.call_at(when, self._send_heartbeat) 

166 

167 def _send_heartbeat(self) -> None: 

168 self._heartbeat_cb = None 

169 

170 # If heartbeat reset is pending (data is being received), skip sending 

171 # the ping and let the reset callback handle rescheduling the heartbeat. 

172 if self._need_heartbeat_reset: 

173 return 

174 

175 loop = self._loop 

176 now = loop.time() 

177 if now < self._heartbeat_when: 

178 # Heartbeat fired too early, reschedule 

179 self._heartbeat_cb = loop.call_at( 

180 self._heartbeat_when, self._send_heartbeat 

181 ) 

182 return 

183 

184 conn = self._conn 

185 timeout_ceil_threshold = ( 

186 conn._connector._timeout_ceil_threshold if conn is not None else 5 

187 ) 

188 when = calculate_timeout_when(now, self._pong_heartbeat, timeout_ceil_threshold) 

189 self._cancel_pong_response_cb() 

190 self._pong_response_cb = loop.call_at(when, self._pong_not_received) 

191 

192 coro = self._writer.send_frame(b"", WSMsgType.PING) 

193 if sys.version_info >= (3, 14): 

194 # Try to send the ping immediately to avoid having to schedule 

195 # the task on the event loop. 

196 if isinstance(loop, BaseEventLoop): 

197 ping_task = asyncio.create_task(coro, eager_start=True) 

198 else: 

199 ping_task = asyncio.Task(coro, loop=loop, eager_start=True) 

200 elif sys.version_info >= (3, 12): 

201 ping_task = asyncio.Task(coro, loop=loop, eager_start=True) 

202 else: 

203 ping_task = asyncio.create_task(coro) 

204 

205 if not ping_task.done(): 

206 self._ping_task = ping_task 

207 ping_task.add_done_callback(self._ping_task_done) 

208 else: 

209 self._ping_task_done(ping_task) 

210 

211 def _ping_task_done(self, task: "asyncio.Task[None]") -> None: 

212 """Callback for when the ping task completes.""" 

213 if not task.cancelled() and (exc := task.exception()): 

214 self._handle_ping_pong_exception(exc) 

215 self._ping_task = None 

216 

217 def _pong_not_received(self) -> None: 

218 self._handle_ping_pong_exception( 

219 ServerTimeoutError(f"No PONG received after {self._pong_heartbeat} seconds") 

220 ) 

221 

222 def _handle_ping_pong_exception(self, exc: BaseException) -> None: 

223 """Handle exceptions raised during ping/pong processing.""" 

224 if self._closed: 

225 return 

226 self._set_closed() 

227 # close() is never reached after this; release the parser here. 

228 self._parser = None 

229 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

230 self._exception = exc 

231 self._response.close() 

232 if self._waiting and not self._closing: 

233 self._reader.feed_data(WSMessageError(data=exc, extra=None)) 

234 

235 def _set_closed(self) -> None: 

236 """Set the connection to closed. 

237 

238 Cancel any heartbeat timers and set the closed flag. 

239 """ 

240 self._closed = True 

241 self._cancel_heartbeat() 

242 

243 def _set_closing(self) -> None: 

244 """Set the connection to closing. 

245 

246 Cancel any heartbeat timers and set the closing flag. 

247 """ 

248 self._closing = True 

249 self._cancel_heartbeat() 

250 

251 @property 

252 def closed(self) -> bool: 

253 return self._closed 

254 

255 @property 

256 def close_code(self) -> int | None: 

257 return self._close_code 

258 

259 @property 

260 def protocol(self) -> str | None: 

261 return self._protocol 

262 

263 @property 

264 def compress(self) -> int: 

265 return self._compress 

266 

267 @property 

268 def client_notakeover(self) -> bool: 

269 return self._client_notakeover 

270 

271 def get_extra_info(self, name: str, default: Any = None) -> Any: 

272 """extra info from connection transport""" 

273 conn = self._response.connection 

274 if conn is None: 

275 return default 

276 transport = conn.transport 

277 if transport is None: 

278 return default 

279 return transport.get_extra_info(name, default) 

280 

281 def exception(self) -> BaseException | None: 

282 return self._exception 

283 

284 async def ping(self, message: bytes = b"") -> None: 

285 await self._writer.send_frame(message, WSMsgType.PING) 

286 

287 async def pong(self, message: bytes = b"") -> None: 

288 await self._writer.send_frame(message, WSMsgType.PONG) 

289 

290 async def send_frame( 

291 self, message: bytes, opcode: WSMsgType, compress: int | None = None 

292 ) -> None: 

293 """Send a frame over the websocket.""" 

294 await self._writer.send_frame(message, opcode, compress) 

295 

296 async def send_str(self, data: str, compress: int | None = None) -> None: 

297 if not isinstance(data, str): 

298 raise TypeError("data argument must be str (%r)" % type(data)) 

299 await self._writer.send_frame( 

300 data.encode("utf-8"), WSMsgType.TEXT, compress=compress 

301 ) 

302 

303 async def send_bytes(self, data: bytes, compress: int | None = None) -> None: 

304 if not isinstance(data, (bytes, bytearray, memoryview)): 

305 raise TypeError("data argument must be byte-ish (%r)" % type(data)) 

306 await self._writer.send_frame(data, WSMsgType.BINARY, compress=compress) 

307 

308 async def send_json( 

309 self, 

310 data: Any, 

311 compress: int | None = None, 

312 *, 

313 dumps: JSONEncoder = DEFAULT_JSON_ENCODER, 

314 ) -> None: 

315 await self.send_str(dumps(data), compress=compress) 

316 

317 async def send_json_bytes( 

318 self, 

319 data: Any, 

320 compress: int | None = None, 

321 *, 

322 dumps: JSONBytesEncoder, 

323 ) -> None: 

324 """Send JSON data using a bytes-returning encoder as a binary frame. 

325 

326 Use this when your JSON encoder (like orjson) returns bytes 

327 instead of str, avoiding the encode/decode overhead. 

328 """ 

329 await self.send_bytes(dumps(data), compress=compress) 

330 

331 async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bool: 

332 # we need to break `receive()` cycle first, 

333 # `close()` may be called from different task 

334 if self._waiting and not self._closing: 

335 assert self._loop is not None 

336 self._close_wait = self._loop.create_future() 

337 self._set_closing() 

338 self._reader.feed_data(WS_CLOSING_MESSAGE) 

339 await self._close_wait 

340 

341 if self._closed: 

342 return False 

343 

344 self._set_closed() 

345 try: 

346 try: 

347 await self._writer.close(code, message) 

348 except asyncio.CancelledError: 

349 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

350 self._response.close() 

351 raise 

352 except Exception as exc: 

353 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

354 self._exception = exc 

355 self._response.close() 

356 return True 

357 

358 if self._close_code: 

359 self._response.close() 

360 return True 

361 

362 while True: 

363 try: 

364 async with async_timeout.timeout(self._timeout.ws_close): 

365 msg = await self._reader.read() 

366 except asyncio.CancelledError: 

367 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

368 self._response.close() 

369 raise 

370 except Exception as exc: 

371 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

372 self._exception = exc 

373 self._response.close() 

374 return True 

375 

376 if msg.type is WSMsgType.CLOSE: 

377 self._close_code = msg.data 

378 self._response.close() 

379 return True 

380 finally: 

381 # Once closed the response can no longer be drained; release the 

382 # parser and the stash it retains. 

383 self._parser = None 

384 

385 @overload 

386 async def receive( 

387 self: "ClientWebSocketResponse[Literal[True]]", timeout: float | None = None 

388 ) -> WSMessageDecodeText: ... 

389 

390 @overload 

391 async def receive( 

392 self: "ClientWebSocketResponse[Literal[False]]", timeout: float | None = None 

393 ) -> WSMessageNoDecodeText: ... 

394 

395 @overload 

396 async def receive( 

397 self: "ClientWebSocketResponse[_DecodeText]", timeout: float | None = None 

398 ) -> WSMessageDecodeText | WSMessageNoDecodeText: ... 

399 

400 async def receive( 

401 self, timeout: float | None = None 

402 ) -> WSMessageDecodeText | WSMessageNoDecodeText: 

403 receive_timeout = timeout or self._timeout.ws_receive 

404 

405 while True: 

406 if self._waiting: 

407 raise RuntimeError("Concurrent call to receive() is not allowed") 

408 

409 if self._closed: 

410 return WS_CLOSED_MESSAGE 

411 elif self._closing: 

412 await self.close() 

413 return WS_CLOSED_MESSAGE 

414 

415 try: 

416 self._waiting = True 

417 try: 

418 if receive_timeout: 

419 # Entering the context manager and creating 

420 # Timeout() object can take almost 50% of the 

421 # run time in this loop so we avoid it if 

422 # there is no read timeout. 

423 async with async_timeout.timeout(receive_timeout): 

424 msg = await self._reader.read() 

425 else: 

426 msg = await self._reader.read() 

427 finally: 

428 self._waiting = False 

429 if self._close_wait: 

430 set_result(self._close_wait, None) 

431 except (asyncio.CancelledError, asyncio.TimeoutError): 

432 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

433 raise 

434 except EofStream: 

435 self._close_code = WSCloseCode.OK 

436 await self.close() 

437 return WS_CLOSED_MESSAGE 

438 except ClientError: 

439 # Likely ServerDisconnectedError when connection is lost. 

440 # close() is not called on this path, so release the parser 

441 # and the stash it retains here. 

442 self._parser = None 

443 self._set_closed() 

444 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

445 return WS_CLOSED_MESSAGE 

446 except WebSocketError as exc: 

447 self._close_code = exc.code 

448 await self.close(code=exc.code) 

449 return WSMessageError(data=exc) 

450 except Exception as exc: 

451 self._exception = exc 

452 self._set_closing() 

453 self._close_code = WSCloseCode.ABNORMAL_CLOSURE 

454 await self.close() 

455 return WSMessageError(data=exc) 

456 

457 if msg.type not in _INTERNAL_RECEIVE_TYPES: 

458 # If its not a close/closing/ping/pong message 

459 # we can return it immediately 

460 return msg 

461 

462 if msg.type is WSMsgType.CLOSE: 

463 self._set_closing() 

464 self._close_code = msg.data 

465 # Could be closed elsewhere while awaiting reader 

466 if not self._closed and self._autoclose: # type: ignore[redundant-expr] 

467 await self.close() 

468 elif msg.type is WSMsgType.CLOSING: 

469 self._set_closing() 

470 elif msg.type is WSMsgType.PING and self._autoping: 

471 await self.pong(msg.data) 

472 continue 

473 elif msg.type is WSMsgType.PONG and self._autoping: 

474 continue 

475 

476 return msg 

477 

478 @overload 

479 async def receive_str( 

480 self: "ClientWebSocketResponse[Literal[True]]", *, timeout: float | None = None 

481 ) -> str: ... 

482 

483 @overload 

484 async def receive_str( 

485 self: "ClientWebSocketResponse[Literal[False]]", *, timeout: float | None = None 

486 ) -> bytes: ... 

487 

488 @overload 

489 async def receive_str( 

490 self: "ClientWebSocketResponse[_DecodeText]", *, timeout: float | None = None 

491 ) -> str | bytes: ... 

492 

493 async def receive_str(self, *, timeout: float | None = None) -> str | bytes: 

494 """Receive TEXT message. 

495 

496 Returns str when decode_text=True (default), bytes when decode_text=False. 

497 """ 

498 msg = await self.receive(timeout) 

499 if msg.type is not WSMsgType.TEXT: 

500 raise WSMessageTypeError( 

501 f"Received message {msg.type}:{msg.data!r} is not WSMsgType.TEXT" 

502 ) 

503 return msg.data 

504 

505 async def receive_bytes(self, *, timeout: float | None = None) -> bytes: 

506 msg = await self.receive(timeout) 

507 if msg.type is not WSMsgType.BINARY: 

508 raise WSMessageTypeError( 

509 f"Received message {msg.type}:{msg.data!r} is not WSMsgType.BINARY" 

510 ) 

511 return msg.data 

512 

513 @overload 

514 async def receive_json( 

515 self: "ClientWebSocketResponse[Literal[True]]", 

516 *, 

517 loads: JSONDecoder = ..., 

518 timeout: float | None = None, 

519 ) -> Any: ... 

520 

521 @overload 

522 async def receive_json( 

523 self: "ClientWebSocketResponse[Literal[False]]", 

524 *, 

525 loads: Callable[[bytes], Any] = ..., 

526 timeout: float | None = None, 

527 ) -> Any: ... 

528 

529 @overload 

530 async def receive_json( 

531 self: "ClientWebSocketResponse[_DecodeText]", 

532 *, 

533 loads: JSONDecoder | Callable[[bytes], Any] = ..., 

534 timeout: float | None = None, 

535 ) -> Any: ... 

536 

537 async def receive_json( 

538 self, 

539 *, 

540 loads: JSONDecoder | Callable[[bytes], Any] = DEFAULT_JSON_DECODER, 

541 timeout: float | None = None, 

542 ) -> Any: 

543 data = await self.receive_str(timeout=timeout) 

544 return loads(data) # type: ignore[arg-type] 

545 

546 def __aiter__(self) -> Self: 

547 return self 

548 

549 @overload 

550 async def __anext__( 

551 self: "ClientWebSocketResponse[Literal[True]]", 

552 ) -> WSMessageDecodeText: ... 

553 

554 @overload 

555 async def __anext__( 

556 self: "ClientWebSocketResponse[Literal[False]]", 

557 ) -> WSMessageNoDecodeText: ... 

558 

559 @overload 

560 async def __anext__( 

561 self: "ClientWebSocketResponse[_DecodeText]", 

562 ) -> WSMessageDecodeText | WSMessageNoDecodeText: ... 

563 

564 async def __anext__(self) -> WSMessageDecodeText | WSMessageNoDecodeText: 

565 msg = await self.receive() 

566 if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED): 

567 raise StopAsyncIteration 

568 return msg 

569 

570 async def __aenter__(self) -> Self: 

571 return self 

572 

573 async def __aexit__( 

574 self, 

575 exc_type: type[BaseException] | None, 

576 exc_val: BaseException | None, 

577 exc_tb: TracebackType | None, 

578 ) -> None: 

579 await self.close()