Coverage for core / src / sensorkit / backend / request.py: 90%

263 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2from __future__ import annotations 

3 

4import asyncio 

5import contextlib 

6import uuid 

7from collections.abc import Awaitable, Coroutine, Generator 

8from dataclasses import dataclass 

9from datetime import UTC, datetime, timedelta 

10from typing import Any, Callable, Literal, cast, final, overload, override 

11 

12from loguru import logger 

13from pydantic import BaseModel 

14 

15from sensorkit.backend.base import StreamContext 

16from sensorkit.backend.event import Event, EventMultiplexer 

17from sensorkit.common.aio import cleanup_future, scoped_waiter 

18 

19 

20class Call[R: BaseModel, V: BaseModel = R](Awaitable[V]): 

21 """An invocation of a simple request.""" 

22 

23 def __init__(self, coro: Coroutine[Any, Any, bytes], response_type: type[R]): 

24 self._coro = coro 

25 self._response_type = response_type 

26 self._future: asyncio.Future[V] = asyncio.get_running_loop().create_future() 

27 self._got_response = False 

28 self.response: R | None = None 

29 

30 self._future.add_done_callback(cleanup_future) 

31 

32 @final 

33 async def _invoke(self, timeout: float) -> R: 

34 # Send request and await response. 

35 async with asyncio.timeout(timeout): 

36 recv = await self._coro 

37 

38 if self._response_type: 

39 self.response = self._response_type.model_validate_json(recv) 

40 

41 self._got_response = True 

42 

43 return self.response 

44 

45 async def invoke(self, timeout: float = 5.0): 

46 """Send the request and populate the future with the parsed response.""" 

47 try: 

48 response = await self._invoke(timeout) 

49 self._future.set_result(response) 

50 return response 

51 except BaseException as e: 

52 self._future.set_exception(e) 

53 raise 

54 

55 async def wait(self): 

56 """Wait until the call's future is resolved.""" 

57 await self._future 

58 

59 def get_future(self) -> asyncio.Future[V]: 

60 """Return the underlying future resolving to the call's final result.""" 

61 return self._future 

62 

63 def done(self): 

64 """Return True if the call has completed (success or error).""" 

65 return self._future.done() 

66 

67 def result(self) -> V: 

68 """Return the call result, raising if the call has not completed or raised.""" 

69 return self._future.result() 

70 

71 async def run_to_completion(self) -> V: 

72 """Invoke the call if not already sent, then await and return the final result.""" 

73 if not self._got_response: 

74 await self.invoke() 

75 

76 await self._future 

77 return self._future.result() 

78 

79 @override 

80 def __await__(self) -> Generator[Any, None, V]: 

81 return self.run_to_completion().__await__() 

82 

83 

84type HandlerFunc[P: BaseModel | None, R: BaseModel | None] = Callable[[P], Coroutine[Any, Any, R]] 

85 

86 

87class CallHandler[P: BaseModel | None, R: BaseModel | None]: 

88 """Callable that handles incoming simple requests.""" 

89 

90 def __init__( 

91 self, 

92 request: Request[P, R, R], 

93 func: HandlerFunc[P, R], 

94 ): 

95 self._request = request 

96 self._handler_func = func 

97 

98 def _run_handler(self, data: P): 

99 return self._handler_func(data) 

100 

101 async def __call__(self, payload: bytes) -> bytes: 

102 # Parse the input payload. 

103 payload_type = self._request.payload 

104 data: P = ( 

105 payload_type.model_validate_json(payload) 

106 if self._request.payload is not None 

107 else None 

108 ) 

109 

110 # Run the user handler func and get the response object. 

111 response = await self._run_handler(data) 

112 

113 # Serialize the response payload. 

114 response_payload = ( 

115 response.model_dump_json().encode() 

116 if response is not None 

117 else b"" 

118 ) 

119 

120 return response_payload 

121 

122 

123class ExtendedResponse(BaseModel): 

124 """A long-running request response.""" 

125 call_id: uuid.UUID = None 

126 call_state: Literal["running", "success", "failure"] = "running" 

127 

128 

129class CallEvent(Event, ExtendedResponse): 

130 """An event that occurs in the context of a long-running request.""" 

131 good_until: datetime | None 

132 payload: Any = None 

133 

134 

135class ExtendedCall[R: ExtendedResponse, V: BaseModel](Call[R, V]): 

136 """A Call extension that supports long-running requests.""" 

137 

138 def __init__( 

139 self, 

140 coro: Coroutine[Any, Any, bytes], 

141 response_type: type[R], 

142 result_type: type[V], 

143 event_mux: EventMultiplexer, 

144 ): 

145 super().__init__(coro, response_type) 

146 self._result_type = result_type 

147 self._event_mux = event_mux 

148 

149 @override 

150 async def invoke(self, timeout: float = 10.0): 

151 # Consume the event stream. 

152 context = contextlib.ExitStack() 

153 queue = context.enter_context(self._event_mux.event_queue(CallEvent)) 

154 

155 try: 

156 await self._event_mux.wait_ready() 

157 

158 # Execute the initial request-response communication. 

159 response = await self._invoke(timeout) 

160 except (asyncio.CancelledError, Exception) as e: 

161 context.close() 

162 self._future.set_exception(e) 

163 raise 

164 

165 if response.call_state == "failure": 

166 # The handler rejected the call. The response is authoritative, so settle here instead 

167 # of leaving the caller on the event stream's timeout. 

168 try: 

169 error = await self._rejection_error(queue, response.call_id) 

170 except (asyncio.CancelledError, Exception) as e: 

171 context.close() 

172 self._future.set_exception(e) 

173 raise 

174 

175 context.close() 

176 self._future.set_exception(error) 

177 raise error 

178 

179 # Start a background task to receive response progress and end events. 

180 started = asyncio.Event() 

181 

182 self._task = asyncio.create_task( 

183 self._await_response_events( 

184 context, 

185 queue, 

186 response.call_id, 

187 started, 

188 timeout, 

189 ) 

190 ) 

191 

192 def extended_call_done(t: asyncio.Task): 

193 # Note we must check the task exception even if the future is already done to avoid 

194 # leaks and warnings. 

195 if t.cancelled(): 

196 self._future.cancel() 

197 elif err := t.exception(): 

198 if not self._future.done(): 

199 self._future.set_exception(err) 

200 elif not self._future.done(): 

201 self._future.set_result(t.result()) 

202 

203 self._task.add_done_callback(extended_call_done) 

204 

205 # Wait until the first event is received or the task ends for some reason, whichever 

206 # comes first. 

207 async with scoped_waiter(started.wait()) as wait: 

208 await asyncio.wait( 

209 [wait, self._task], 

210 return_when=asyncio.FIRST_COMPLETED, 

211 ) 

212 

213 return response 

214 

215 async def _rejection_error( 

216 self, 

217 queue: asyncio.Queue[Event], 

218 response_id: uuid.UUID, 

219 timeout: float = 1.0, 

220 ) -> CallError: 

221 """Return the error for a rejected call, carrying the handler's reason where available. 

222 

223 The reason travels on the failure event rather than the response, and the event stream may 

224 be a different transport than the request that just delivered the rejection. The event is 

225 published ahead of that response, so it is normally waiting in the queue already; if it 

226 does not arrive within timeout seconds, the rejection still stands and is reported without 

227 a reason. 

228 

229 Args: 

230 queue: The call's registered CallEvent queue. 

231 response_id: The call id to match events against. 

232 timeout: How long to wait for the failure event before giving up on the reason. 

233 """ 

234 try: 

235 async with asyncio.timeout(timeout): 

236 while True: 

237 event = await queue.get() 

238 queue.task_done() 

239 

240 if not isinstance(event, CallEvent) or event.call_id != response_id: 

241 continue 

242 

243 # Rejecting leaves the response done, so a handler is free to emit progress 

244 # before its failure event. The reason rides on the latter. 

245 if event.call_state == "failure": 

246 return CallError(f"response failed: {event.payload}") 

247 except TimeoutError: 

248 return CallError("call rejected") 

249 

250 async def _await_response_events( 

251 self, 

252 context: contextlib.ExitStack, 

253 queue: asyncio.Queue[Event], 

254 response_id: uuid.UUID, 

255 started: asyncio.Event, 

256 timeout: float, 

257 ) -> V: 

258 with context: 

259 while True: 

260 async with asyncio.timeout(timeout): 

261 # Consume the next matching ResponseEvent. 

262 event = await queue.get() 

263 queue.task_done() 

264 

265 if not isinstance(event, CallEvent) or event.call_id != response_id: 

266 continue 

267 

268 match event.call_state: 

269 case "success": 

270 return cast( 

271 V, 

272 self._result_type.model_validate(event.payload) 

273 if self._result_type is not None 

274 else None 

275 ) 

276 case "failure": 

277 raise CallError(f"response failed: {event.payload}") 

278 

279 # Signal that at least one matching response event has been received. 

280 started.set() 

281 

282 # Figure the next timeout. This relies on peer clock synchronization. 

283 timeout = (event.good_until - datetime.now(UTC)).total_seconds() 

284 

285 # Unreachable. 

286 raise RuntimeError 

287 

288 

289type ExtendedHandlerFunc[P: BaseModel | None, R: BaseModel | None, V: BaseModel | None] = ( 

290 Callable[ 

291 [P, CallContext[R, V]], 

292 Coroutine[Any, Any, None] 

293 ] 

294) 

295 

296 

297class CallContext[R: ExtendedResponse | None, V: BaseModel | None]: 

298 """Context for an incoming extended request currently being handled.""" 

299 

300 def __init__(self, call_id: uuid.UUID, stream: StreamContext): 

301 self.call_id = call_id 

302 self._stream = stream 

303 self.response = asyncio.get_running_loop().create_future() 

304 self.finalized = False 

305 

306 def accept(self, *, response: R): 

307 """Mark the call as accepted and set the initial response, allowing progress events to follow.""" 

308 if self.response.done(): 

309 raise CallHandlerResponseError(responded=True) 

310 

311 if response is None: 

312 response = ExtendedResponse() 

313 

314 response.call_id = self.call_id 

315 response.call_state = "running" 

316 self.response.set_result(response) 

317 

318 def reject(self, *, response: R): 

319 """Reject the call immediately, returning a failure response without further events.""" 

320 if self.response.done(): 

321 raise CallHandlerResponseError(responded=True) 

322 

323 if response is None: 

324 response = ExtendedResponse() 

325 

326 response.call_id = self.call_id 

327 response.call_state = "failure" 

328 self.response.set_result(response) 

329 

330 async def progress(self, ttl: float, payload: Any = None): 

331 """Emit a progress event, extending the caller's deadline by ttl seconds.""" 

332 if not self.response.done(): 

333 raise CallHandlerResponseError(responded=False) 

334 

335 await self._call_event( 

336 CallEvent( 

337 call_id=self.call_id, 

338 call_state="running", 

339 good_until=datetime.now(UTC) + timedelta(seconds=ttl), 

340 payload=payload, 

341 ) 

342 ) 

343 

344 async def progress_from_task( 

345 self, 

346 task: asyncio.Task, 

347 *, 

348 cadence: float, 

349 ttl: float, 

350 payload_func: Callable[[], Any] | None = None, 

351 ): 

352 """Emit progress events at the given cadence until task completes, then await the task.""" 

353 done = None 

354 fs = [task] 

355 

356 while not done: 

357 await self.progress(ttl, payload_func() if payload_func else None) 

358 done, _ = await asyncio.wait(fs, timeout=cadence) 

359 

360 # Raise if the task raised. 

361 await task 

362 

363 async def succeed(self, *, result: V): 

364 """Emit a success event carrying the final result, completing the extended call.""" 

365 if not self.response.done(): 

366 raise CallHandlerResponseError(responded=False) 

367 

368 if self.response.result().call_state == "failure": 

369 raise CallHandlerError("Cannot send success result for rejected call") 

370 

371 await self._call_event( 

372 CallEvent( 

373 call_id=self.call_id, 

374 call_state="success", 

375 good_until=None, 

376 payload=result, 

377 ), 

378 finalize=True, 

379 ) 

380 

381 async def fail(self, payload: Any = None): 

382 """Emit a failure event, completing the extended call in a failed state.""" 

383 if not self.response.done(): 

384 raise CallHandlerResponseError(responded=False) 

385 

386 await self._call_event( 

387 CallEvent( 

388 call_id=self.call_id, 

389 call_state="failure", 

390 good_until=None, 

391 payload=payload, 

392 ), 

393 finalize=True, 

394 ) 

395 

396 async def _call_event(self, event: CallEvent, finalize: bool = False): 

397 if self.finalized: 

398 raise CallHandlerError("Call has already been finalized") 

399 

400 self.finalized = finalize 

401 

402 await self._stream.publish_event(event.model_dump_json().encode()) 

403 

404 

405class ExtendedCallHandler[P: BaseModel | None, R: ExtendedResponse, V: BaseModel]( 

406 CallHandler[P, R] 

407): 

408 """Callable that handles incoming long-running requests.""" 

409 

410 def __init__( 

411 self, 

412 request: Request[P, R, V], 

413 func: ExtendedHandlerFunc[P, R, V], 

414 stream: StreamContext, 

415 ): 

416 super().__init__(request, func) # noqa 

417 self._handler_func = func 

418 self._stream = stream 

419 

420 @override 

421 async def _run_handler(self, data: P): 

422 # Create the call context object and run the call handler func. 

423 context = CallContext(uuid.uuid1(), self._stream) 

424 handler_task = asyncio.create_task(self._handler_func(data, context)) 

425 

426 # Handle the initial response and error paths. 

427 await asyncio.wait( 

428 [ 

429 handler_task, 

430 context.response, 

431 ], 

432 return_when=asyncio.FIRST_COMPLETED, 

433 ) 

434 

435 if handler_task.done(): 

436 # The handler func exited already. If it raised, fail. 

437 if e := handler_task.exception(): 

438 if not context.finalized: 

439 await context.fail(str(e)) 

440 

441 logger.warning(f"Exception in call handler: {e}") 

442 raise e 

443 

444 if not context.response.done(): 

445 # The handler func did not call `accept` or `reject`. 

446 await context.fail() 

447 raise CallHandlerResponseError(responded=False) 

448 

449 # Get the initial response object. 

450 response = context.response.result() 

451 response.call_id = context.call_id 

452 

453 if response.call_state == "failure": 

454 # The handler rejected the request. Make sure a fail event is sent. 

455 if not context.finalized: 

456 await context.fail("Request rejected") 

457 

458 # Ensure exceptions raised in the handler are propagated. 

459 def call_handler_done(_): 

460 if not context.finalized and not handler_task.cancelled(): 

461 payload = None 

462 

463 if e := handler_task.exception(): 

464 payload = str(e) 

465 

466 logger.debug(f"failing unfinalized call handler {e}") 

467 _t = asyncio.create_task(context.fail(payload)) 

468 

469 handler_task.add_done_callback(call_handler_done) 

470 return response 

471 

472 

473class CallError(Exception): 

474 """Raised when a call method fails.""" 

475 

476 

477class CallHandlerError(CallError): 

478 """Raised when a call method is called in an invalid state.""" 

479 

480 

481class CallHandlerResponseError(CallHandlerError): 

482 def __init__(self, *, responded: bool): 

483 self.responded = responded 

484 problem = "has already called" if responded else "did not call" 

485 super().__init__(f"Call handler {problem} `accept` or `reject`") 

486 

487 

488@dataclass 

489class Request[P: BaseModel | None, R: BaseModel | None, V: BaseModel | None]: 

490 """A declaration of a callable request. 

491 

492 If the response type is `ExtendedResponse` or a subclass of it, the request is considered 

493 "extended". 

494 """ 

495 name: str 

496 """The name of the request.""" 

497 payload: type[P] 

498 """The type expected to be sent in the request call.""" 

499 response: type[R] 

500 """The type expected to be received in the initial call response.""" 

501 result: type[V] 

502 """The type of the payload of the success ResponseEvent in an extended request. 

503 Always equal to the response type for simple requests.""" 

504 

505 def is_extended(self): 

506 """Return True if this is a long-running request using ExtendedResponse.""" 

507 return self.response and issubclass(self.response, ExtendedResponse) 

508 

509 def create_handler( 

510 self, 

511 func: HandlerFunc[P, R] | ExtendedHandlerFunc[P, R, V], 

512 stream: StreamContext | None = None, 

513 ) -> CallHandler[P, R]: 

514 """Construct the appropriate CallHandler or ExtendedCallHandler for this request.""" 

515 if self.is_extended(): 

516 return ExtendedCallHandler(self, func, stream) 

517 else: 

518 return CallHandler(self, func) 

519 

520 @classmethod 

521 @overload 

522 def define[R: ExtendedResponse, P: BaseModel | None = None]( 

523 cls, 

524 name: str, 

525 *, 

526 payload: type[P] | None = None, 

527 response: type[R], 

528 ) -> Request[P, R, None]: 

529 """Define a long-running request that uses the entity event stream to track progress.""" 

530 

531 @classmethod 

532 @overload 

533 def define[V: BaseModel, R: ExtendedResponse = ExtendedResponse, P: BaseModel | None = None]( 

534 cls, 

535 name: str, 

536 *, 

537 payload: type[P] | None = None, 

538 response: type[R] = ExtendedResponse, 

539 result: type[V], 

540 ) -> Request[P, R, V]: 

541 """Define a long-running request that uses the entity event stream to track progress.""" 

542 

543 @classmethod 

544 @overload 

545 def define[R: BaseModel | None = None, P: BaseModel | None = None]( 

546 cls, 

547 name: str, 

548 *, 

549 payload: type[P] | None = None, 

550 response: type[R] | None = None, 

551 ) -> Request[P, R, R]: 

552 """Define a simple request that requires an immediate response.""" 

553 

554 @classmethod 

555 def define( 

556 cls, 

557 name: str, 

558 payload = None, 

559 response = None, 

560 result = None, 

561 ): 

562 if response and issubclass(response, ExtendedResponse): 

563 # This is an extended request. No checks or defaults are needed in this case. 

564 return cls(name, payload, response, result) 

565 elif result: 

566 # Here a result type is given but no response type. In this case, having a result means 

567 # this is an extended request, so we default to the bare ExtendedResponse type. 

568 if response is not None: 

569 raise TypeError("simple request cannot have a result type") 

570 

571 return cls(name, payload, ExtendedResponse, result) 

572 else: 

573 # Simple requests always treat their response as the result. 

574 return cls(name, payload, response, result or response)