dataapp_crawler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,735 @@
1
+ """Bounded, run-local anonymous acquisition sessions.
2
+
3
+ HTTP cookies and browser context state persist independently inside one slot by
4
+ default. An explicitly enabled cookie bridge can reconcile cookies only; browser
5
+ storage state and identity headers remain engine-local.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ import math
13
+ import sys
14
+ import time
15
+ from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
16
+ from contextlib import asynccontextmanager
17
+ from copy import deepcopy
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+ from uuid import uuid4
21
+
22
+ import httpx
23
+
24
+ from .cookies import CookieState, cookies_for_browser, validate_shareable_cookies
25
+ from .errors import ConfigurationError, FetchError
26
+ from .http import HttpSession
27
+ from .models import Identity, Limits
28
+
29
+ CloseResource = Callable[[Any], Awaitable[None]]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SessionInfo:
34
+ """Safe metadata passed to factories; ``health`` is a lower-is-better error score."""
35
+
36
+ id: str
37
+ health: int
38
+ use_count: int
39
+ age: float
40
+
41
+
42
+ Factory = Callable[[SessionInfo], Any]
43
+
44
+
45
+ class Session:
46
+ """The current exclusive session lease exposed through ``context.session``."""
47
+
48
+ __slots__ = ("_slot", "_active")
49
+
50
+ def __init__(self, slot: _Slot) -> None:
51
+ self._slot: _Slot | None = slot
52
+ self._active = True
53
+
54
+ def _check(self) -> None:
55
+ if not self._active or self._slot is None:
56
+ raise RuntimeError("Session lease is no longer active")
57
+
58
+ def _invalidate(self) -> None:
59
+ self._active = False
60
+ self._slot = None
61
+
62
+ @property
63
+ def active(self) -> bool:
64
+ """Whether this view still owns its exclusive lease."""
65
+ return self._active
66
+
67
+ @property
68
+ def id(self) -> str:
69
+ self._check()
70
+ assert self._slot is not None
71
+ return self._slot.id
72
+
73
+ @property
74
+ def health(self) -> int:
75
+ """Current error score; zero is healthiest and ``mark_good`` lowers it."""
76
+ self._check()
77
+ assert self._slot is not None
78
+ return self._slot.error_score
79
+
80
+ @property
81
+ def use_count(self) -> int:
82
+ self._check()
83
+ assert self._slot is not None
84
+ return self._slot.use_count
85
+
86
+ @property
87
+ def age(self) -> float:
88
+ self._check()
89
+ assert self._slot is not None
90
+ return max(0.0, time.monotonic() - self._slot.created_at)
91
+
92
+ def mark_good(self) -> None:
93
+ self._check()
94
+ assert self._slot is not None
95
+ self._slot.error_score = max(0, self._slot.error_score - 1)
96
+
97
+ def mark_bad(self) -> None:
98
+ self._check()
99
+ assert self._slot is not None
100
+ self._slot.error_score += 1
101
+
102
+ def retire(self) -> None:
103
+ self._check()
104
+ assert self._slot is not None
105
+ self._slot.retire = True
106
+
107
+ def __repr__(self) -> str:
108
+ if not self._active:
109
+ return "Session(inactive)"
110
+ return (
111
+ f"Session(id={self.id!r}, health={self.health}, "
112
+ f"use_count={self.use_count}, age={self.age:.3f})"
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class _Slot:
118
+ id: str
119
+ affinity: tuple[str, str]
120
+ origin: str
121
+ identity: Identity
122
+ proxy: Any
123
+ http: HttpSession
124
+ browser_provider: Any
125
+ created_at: float = field(default_factory=time.monotonic)
126
+ last_used: float = field(default_factory=time.monotonic)
127
+ use_count: int = 0
128
+ error_score: int = 0
129
+ leased: bool = False
130
+ retire: bool = False
131
+ browser: Any = None
132
+ browser_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
133
+ cookie_state: CookieState | None = None
134
+
135
+ @property
136
+ def info(self) -> SessionInfo:
137
+ return SessionInfo(
138
+ self.id,
139
+ self.error_score,
140
+ self.use_count,
141
+ max(0.0, time.monotonic() - self.created_at),
142
+ )
143
+
144
+
145
+ class SessionLease:
146
+ """Internal resources for one exclusive pool lease."""
147
+
148
+ __slots__ = ("_slot", "session")
149
+
150
+ def __init__(self, slot: _Slot) -> None:
151
+ self._slot = slot
152
+ self.session = Session(slot)
153
+
154
+ @property
155
+ def http(self) -> HttpSession:
156
+ return self._slot.http
157
+
158
+ @property
159
+ def identity(self) -> Identity:
160
+ return self._slot.identity
161
+
162
+ @property
163
+ def proxy(self) -> Any:
164
+ return self._slot.proxy
165
+
166
+ async def sync_to_http(self) -> None:
167
+ """Apply canonical cookies before HTTP acquisition when sharing is enabled."""
168
+ state = self._slot.cookie_state
169
+ if state is not None:
170
+ self._slot.http.replace_cookies(state.snapshot())
171
+
172
+ async def sync_from_http(self) -> None:
173
+ """Commit HTTP response cookie mutations, including deletions."""
174
+ state = self._slot.cookie_state
175
+ if state is not None:
176
+ state.replace(self._slot.http.export_cookies())
177
+
178
+ async def sync_to_browser(self, handle: Any) -> None:
179
+ """Apply canonical cookies to an isolated browser context."""
180
+ state = self._slot.cookie_state
181
+ if state is None:
182
+ return
183
+ context = self._cookie_context(handle)
184
+ existing = await context.cookies()
185
+ validate_shareable_cookies(existing)
186
+ await context.clear_cookies()
187
+ cookies = cookies_for_browser(state.snapshot())
188
+ if cookies:
189
+ await context.add_cookies(cookies)
190
+
191
+ async def sync_from_browser(self, handle: Any) -> None:
192
+ """Commit browser cookie mutations after process code has finished."""
193
+ state = self._slot.cookie_state
194
+ if state is None:
195
+ return
196
+ context = self._cookie_context(handle)
197
+ state.replace(await context.cookies())
198
+
199
+ @staticmethod
200
+ def _cookie_context(handle: Any) -> Any:
201
+ if getattr(handle, "owned", None) is not True:
202
+ raise ConfigurationError("Cookie sharing requires an owned browser context")
203
+ if "cookies" not in getattr(handle, "capabilities", frozenset()):
204
+ raise ConfigurationError("Browser Provider does not support cookie sharing")
205
+ context = getattr(handle, "context", None)
206
+ if context is None or any(
207
+ not callable(getattr(context, name, None))
208
+ for name in ("cookies", "clear_cookies", "add_cookies")
209
+ ):
210
+ raise ConfigurationError("Browser context does not support cookie sharing")
211
+ return context
212
+
213
+ async def browser(self, *, required_capabilities: frozenset[str]) -> Any:
214
+ async with self._slot.browser_lock:
215
+ if self._slot.browser is None:
216
+ provider = self._slot.browser_provider
217
+ if provider is None:
218
+ raise ConfigurationError("A browser Provider is required for browser mode")
219
+ self._slot.browser = await provider.open(
220
+ identity=self._slot.identity, proxy=self._slot.proxy
221
+ )
222
+ handle = self._slot.browser
223
+ if "borrowed" in handle.capabilities:
224
+ self._slot.retire = True
225
+ failure = ConfigurationError(
226
+ "Borrowed browser contexts cannot be isolated as SessionPool slots"
227
+ )
228
+ try:
229
+ await asyncio.wait_for(handle.close(), timeout=10)
230
+ except asyncio.CancelledError:
231
+ raise
232
+ except Exception:
233
+ failure.add_note("Borrowed browser rejection cleanup also failed")
234
+ else:
235
+ self._slot.browser = None
236
+ raise failure
237
+ if required_capabilities - handle.capabilities:
238
+ raise ConfigurationError(
239
+ "Browser Provider does not support required capabilities"
240
+ )
241
+ return handle
242
+
243
+
244
+ class SessionPool:
245
+ """A bounded pool of anonymous, origin-affine acquisition generations.
246
+
247
+ A pool is single-use and binds to exactly one :class:`Crawler`. Factories are
248
+ called once per slot generation with safe :class:`SessionInfo`; a proxy factory
249
+ may return either a handle or a provider exposing ``acquire()``. A fixed handle
250
+ preserves configuration only and is not proof of a physically sticky exit IP.
251
+ """
252
+
253
+ def __init__(
254
+ self,
255
+ *,
256
+ max_size: int = 4,
257
+ max_age: float = 300,
258
+ max_usage: int = 100,
259
+ max_error_score: int = 3,
260
+ browser_factory: Factory | None = None,
261
+ proxy_factory: Factory | None = None,
262
+ share_cookies: bool = False,
263
+ ) -> None:
264
+ if isinstance(max_size, bool) or not isinstance(max_size, int) or max_size < 1:
265
+ raise ConfigurationError("max_size must be a positive integer")
266
+ if (
267
+ isinstance(max_usage, bool)
268
+ or not isinstance(max_usage, int)
269
+ or max_usage < 1
270
+ ):
271
+ raise ConfigurationError("max_usage must be a positive integer")
272
+ if (
273
+ isinstance(max_error_score, bool)
274
+ or not isinstance(max_error_score, int)
275
+ or max_error_score < 1
276
+ ):
277
+ raise ConfigurationError("max_error_score must be a positive integer")
278
+ if (
279
+ isinstance(max_age, bool)
280
+ or not isinstance(max_age, (int, float))
281
+ or not math.isfinite(max_age)
282
+ or max_age <= 0
283
+ ):
284
+ raise ConfigurationError("max_age must be a finite positive number")
285
+ if not isinstance(share_cookies, bool):
286
+ raise ConfigurationError("share_cookies must be a boolean")
287
+ self.max_size = max_size
288
+ self.max_age = float(max_age)
289
+ self.max_usage = max_usage
290
+ self.max_error_score = max_error_score
291
+ self.browser_factory = browser_factory
292
+ self.proxy_factory = proxy_factory
293
+ self.share_cookies = share_cookies
294
+ self._condition = asyncio.Condition()
295
+ self._slots: list[_Slot] = []
296
+ self._retired: list[_Slot] = []
297
+ self._closing: set[int] = set()
298
+ self._orphaned: list[Any] = []
299
+ self._creating = 0
300
+ self._pending_affinities: set[tuple[str, str]] = set()
301
+ self._bound = False
302
+ self._closed = False
303
+ self._limits: Limits | None = None
304
+ self._identity: Identity | None = None
305
+ self._transport: httpx.AsyncBaseTransport | None = None
306
+ self._browser_provider: Any = None
307
+ self._close_resource: CloseResource | None = None
308
+
309
+ def __repr__(self) -> str:
310
+ return (
311
+ f"SessionPool(max_size={self.max_size}, max_age={self.max_age}, "
312
+ f"max_usage={self.max_usage}, max_error_score={self.max_error_score}, "
313
+ f"share_cookies={self.share_cookies})"
314
+ )
315
+
316
+ def bind(
317
+ self,
318
+ *,
319
+ limits: Limits,
320
+ identity: Identity,
321
+ transport: httpx.AsyncBaseTransport | None,
322
+ browser_provider: Any,
323
+ close_resource: CloseResource,
324
+ ) -> None:
325
+ if self._bound:
326
+ raise ConfigurationError("A SessionPool cannot be shared by multiple Crawlers")
327
+ self._bound = True
328
+ self._limits = limits
329
+ self._identity = identity
330
+ self._transport = transport
331
+ self._browser_provider = browser_provider
332
+ self._close_resource = close_resource
333
+
334
+ @asynccontextmanager
335
+ async def lease(self, affinity: tuple[str, str], *, origin: str) -> AsyncIterator[SessionLease]:
336
+ slot = await self._acquire(affinity, origin=origin)
337
+ lease = SessionLease(slot)
338
+ try:
339
+ yield lease
340
+ finally:
341
+ primary_error = sys.exc_info()[1]
342
+ lease.session._invalidate()
343
+ release = asyncio.create_task(self._release(slot))
344
+ try:
345
+ await asyncio.shield(release)
346
+ except asyncio.CancelledError as cancellation:
347
+ pending_cleanup_error: BaseException | None = None
348
+ try:
349
+ if not release.done():
350
+ await asyncio.shield(release)
351
+ release.result()
352
+ except BaseException as caught_cleanup_error:
353
+ pending_cleanup_error = caught_cleanup_error
354
+ if pending_cleanup_error is not None:
355
+ if primary_error is None:
356
+ raise pending_cleanup_error from None
357
+ primary_error.add_note("Session resource cleanup also failed")
358
+ elif primary_error is None:
359
+ raise cancellation from None
360
+ except BaseException:
361
+ if primary_error is None:
362
+ raise
363
+ primary_error.add_note("Session resource cleanup also failed")
364
+
365
+ async def _acquire(self, affinity: tuple[str, str], *, origin: str) -> _Slot:
366
+ while True:
367
+ retired: _Slot | None = None
368
+ create = False
369
+ async with self._condition:
370
+ if self._closed:
371
+ raise RuntimeError("SessionPool is closed")
372
+ matching = next((slot for slot in self._slots if slot.affinity == affinity), None)
373
+ if matching is not None:
374
+ if matching.origin != origin:
375
+ raise ConfigurationError(
376
+ "One explicit session_key cannot be shared across origins"
377
+ )
378
+ if not matching.leased:
379
+ if self._expired(matching):
380
+ self._slots.remove(matching)
381
+ self._retired.append(matching)
382
+ self._pending_affinities.add(affinity)
383
+ retired = matching
384
+ else:
385
+ matching.leased = True
386
+ return matching
387
+ elif affinity in self._pending_affinities:
388
+ await self._condition.wait()
389
+ continue
390
+ elif len(self._slots) + self._creating < self.max_size:
391
+ self._creating += 1
392
+ self._pending_affinities.add(affinity)
393
+ create = True
394
+ else:
395
+ idle = [slot for slot in self._slots if not slot.leased]
396
+ if idle:
397
+ retired = min(idle, key=lambda slot: slot.last_used)
398
+ self._slots.remove(retired)
399
+ self._retired.append(retired)
400
+ self._creating += 1
401
+ self._pending_affinities.add(affinity)
402
+ create = True
403
+ if retired is None and not create:
404
+ await self._condition.wait()
405
+ continue
406
+ if retired is not None:
407
+ try:
408
+ await self._close_tracked(retired)
409
+ except BaseException as error:
410
+ if create:
411
+ cancellation, cleanup_error = await self._complete_cleanup(
412
+ self._release_creation_reservation(affinity)
413
+ )
414
+ if cleanup_error is not None:
415
+ error.add_note("Session creation reservation cleanup also failed")
416
+ if cancellation is not None and not isinstance(
417
+ error, asyncio.CancelledError
418
+ ):
419
+ error.add_note("Session cleanup was also cancelled")
420
+ else:
421
+ cancellation, cleanup_error = await self._complete_cleanup(
422
+ self._discard_pending_affinity(affinity)
423
+ )
424
+ if cleanup_error is not None:
425
+ error.add_note("Session affinity cleanup also failed")
426
+ if cancellation is not None and not isinstance(
427
+ error, asyncio.CancelledError
428
+ ):
429
+ error.add_note("Session cleanup was also cancelled")
430
+ raise
431
+ if not create:
432
+ cancellation, cleanup_error = await self._complete_cleanup(
433
+ self._discard_pending_affinity(affinity)
434
+ )
435
+ if cleanup_error is not None:
436
+ raise cleanup_error from None
437
+ if cancellation is not None:
438
+ raise cancellation from None
439
+ continue
440
+ if create:
441
+ try:
442
+ slot = await self._create_slot(affinity, origin=origin)
443
+ except BaseException as error:
444
+ cancellation, cleanup_error = await self._complete_cleanup(
445
+ self._release_creation_reservation(affinity)
446
+ )
447
+ if cleanup_error is not None:
448
+ error.add_note("Session creation reservation cleanup also failed")
449
+ if cancellation is not None and not isinstance(error, asyncio.CancelledError):
450
+ error.add_note("Session cleanup was also cancelled")
451
+ raise
452
+ try:
453
+ async with self._condition:
454
+ if self._closed:
455
+ close_new = True
456
+ else:
457
+ self._creating -= 1
458
+ self._pending_affinities.discard(affinity)
459
+ slot.leased = True
460
+ self._slots.append(slot)
461
+ close_new = False
462
+ self._condition.notify_all()
463
+ except BaseException as error:
464
+ cancellation, cleanup_error = await self._complete_cleanup(
465
+ self._rollback_created_slot(slot, affinity)
466
+ )
467
+ if cleanup_error is not None:
468
+ error.add_note("Created session rollback also failed")
469
+ if cancellation is not None and not isinstance(error, asyncio.CancelledError):
470
+ error.add_note("Session rollback was also cancelled")
471
+ raise
472
+ if close_new:
473
+ cancellation, cleanup_error = await self._complete_cleanup(
474
+ self._rollback_created_slot(slot, affinity)
475
+ )
476
+ if cleanup_error is not None:
477
+ raise cleanup_error from None
478
+ if cancellation is not None:
479
+ raise cancellation from None
480
+ raise RuntimeError("SessionPool closed while creating a session")
481
+ return slot
482
+
483
+ async def _release_creation_reservation(self, affinity: tuple[str, str]) -> None:
484
+ async with self._condition:
485
+ assert self._creating > 0
486
+ self._creating -= 1
487
+ self._pending_affinities.discard(affinity)
488
+ self._condition.notify_all()
489
+
490
+ async def _discard_pending_affinity(self, affinity: tuple[str, str]) -> None:
491
+ async with self._condition:
492
+ self._pending_affinities.discard(affinity)
493
+ self._condition.notify_all()
494
+
495
+ async def _rollback_created_slot(
496
+ self, slot: _Slot, affinity: tuple[str, str]
497
+ ) -> None:
498
+ close_error: BaseException | None = None
499
+ try:
500
+ await self._close_slot(slot)
501
+ except BaseException as error:
502
+ close_error = error
503
+ async with self._condition:
504
+ if close_error is not None and slot not in self._retired:
505
+ self._retired.append(slot)
506
+ assert self._creating > 0
507
+ self._creating -= 1
508
+ self._pending_affinities.discard(affinity)
509
+ self._condition.notify_all()
510
+ if close_error is not None:
511
+ raise close_error from None
512
+
513
+ @staticmethod
514
+ async def _complete_cleanup(
515
+ cleanup: Coroutine[Any, Any, None],
516
+ ) -> tuple[asyncio.CancelledError | None, BaseException | None]:
517
+ task: asyncio.Task[None] = asyncio.create_task(cleanup)
518
+ cancellation: asyncio.CancelledError | None = None
519
+ while not task.done():
520
+ try:
521
+ await asyncio.shield(task)
522
+ except asyncio.CancelledError as error:
523
+ cancellation = cancellation or error
524
+ except BaseException:
525
+ # The completed task's exception is classified below so caller
526
+ # cancellation and cleanup failure can both be preserved.
527
+ pass
528
+ try:
529
+ task.result()
530
+ except BaseException as error:
531
+ return cancellation, error
532
+ return cancellation, None
533
+
534
+ async def _create_slot(self, affinity: tuple[str, str], *, origin: str) -> _Slot:
535
+ assert self._limits is not None and self._identity is not None
536
+ session_id = uuid4().hex
537
+ info = SessionInfo(session_id, 0, 0, 0.0)
538
+ proxy = None
539
+ browser_provider = self._browser_provider
540
+ try:
541
+ if self.proxy_factory is not None:
542
+ proxy = await _resolve(self.proxy_factory(info))
543
+ acquire = getattr(proxy, "acquire", None)
544
+ if callable(acquire):
545
+ proxy = await _resolve(acquire())
546
+ if self.browser_factory is not None:
547
+ browser_provider = await _resolve(self.browser_factory(info))
548
+ self._validate_browser_provider(browser_provider)
549
+ identity = deepcopy(self._identity)
550
+ if self.share_cookies:
551
+ validate_shareable_cookies(
552
+ [*(identity.storage_state or {}).get("cookies", []), *identity.cookies]
553
+ )
554
+ http = HttpSession(
555
+ self._limits,
556
+ identity,
557
+ proxy_url=proxy.http_url() if proxy is not None else None,
558
+ transport=self._transport,
559
+ close_transport=self._transport is None,
560
+ reject_partitioned=self.share_cookies,
561
+ )
562
+ except BaseException as error:
563
+ cleanup_failed = False
564
+ if proxy is not None:
565
+ cancellation, cleanup_error = await self._complete_cleanup(
566
+ self._close_one(proxy)
567
+ )
568
+ if cleanup_error is not None:
569
+ cleanup_failed = True
570
+ _, orphan_error = await self._complete_cleanup(
571
+ self._track_orphan(proxy)
572
+ )
573
+ cleanup_failed = orphan_error is not None or cleanup_failed
574
+ if cancellation is not None and not isinstance(error, asyncio.CancelledError):
575
+ error.add_note("Partial session cleanup was also cancelled")
576
+ if not isinstance(error, Exception):
577
+ if cleanup_failed:
578
+ error.add_note("Partial session resource cleanup also failed")
579
+ raise
580
+ failure = ConfigurationError("Session resource creation failed")
581
+ if cleanup_failed:
582
+ failure.add_note("Partial session resource cleanup also failed")
583
+ raise failure from None
584
+ return _Slot(
585
+ session_id,
586
+ affinity,
587
+ origin,
588
+ identity,
589
+ proxy,
590
+ http,
591
+ browser_provider,
592
+ cookie_state=CookieState.from_jar(http.client.cookies.jar)
593
+ if self.share_cookies
594
+ else None,
595
+ )
596
+
597
+ async def _release(self, slot: _Slot) -> None:
598
+ close = False
599
+ async with self._condition:
600
+ slot.use_count += 1
601
+ slot.last_used = time.monotonic()
602
+ slot.leased = False
603
+ if self._closed or self._expired(slot):
604
+ if slot in self._slots:
605
+ self._slots.remove(slot)
606
+ if slot not in self._retired:
607
+ self._retired.append(slot)
608
+ close = True
609
+ self._condition.notify_all()
610
+ if close:
611
+ await self._close_tracked(slot)
612
+
613
+ def _expired(self, slot: _Slot) -> bool:
614
+ return (
615
+ slot.retire
616
+ or time.monotonic() - slot.created_at >= self.max_age
617
+ or slot.use_count >= self.max_usage
618
+ or slot.error_score >= self.max_error_score
619
+ )
620
+
621
+ async def retire_idle(self) -> None:
622
+ """Retire all currently idle generations; active leases finish normally."""
623
+ async with self._condition:
624
+ idle = [slot for slot in self._slots if not slot.leased]
625
+ for slot in idle:
626
+ self._slots.remove(slot)
627
+ self._retired.append(slot)
628
+ self._condition.notify_all()
629
+ errors = await asyncio.gather(
630
+ *(self._close_tracked(slot) for slot in idle), return_exceptions=True
631
+ )
632
+ if any(isinstance(error, BaseException) for error in errors):
633
+ raise FetchError("One or more session resources failed to close") from None
634
+
635
+ async def close(self) -> None:
636
+ async with self._condition:
637
+ self._closed = True
638
+ self._condition.notify_all()
639
+ while (
640
+ any(slot.leased for slot in self._slots)
641
+ or self._creating
642
+ or self._closing
643
+ ):
644
+ await self._condition.wait()
645
+ for slot in self._slots:
646
+ if slot not in self._retired:
647
+ self._retired.append(slot)
648
+ slots = list(self._retired)
649
+ orphaned = list(self._orphaned)
650
+ self._slots = []
651
+ self._condition.notify_all()
652
+ errors = await asyncio.gather(
653
+ *(self._close_tracked(slot) for slot in slots),
654
+ *(self._close_orphan(resource) for resource in orphaned),
655
+ return_exceptions=True,
656
+ )
657
+ if any(isinstance(error, BaseException) for error in errors):
658
+ raise FetchError("One or more session resources failed to close") from None
659
+
660
+ async def _close_slot(self, slot: _Slot) -> None:
661
+ errors: list[BaseException] = []
662
+ for resource in (slot.browser, slot.http, slot.proxy):
663
+ try:
664
+ await self._close_one(resource)
665
+ except BaseException as error:
666
+ errors.append(error)
667
+ if errors:
668
+ raise FetchError("Session resource cleanup failed") from None
669
+
670
+ async def _close_tracked(self, slot: _Slot) -> None:
671
+ async with self._condition:
672
+ while id(slot) in self._closing:
673
+ await self._condition.wait()
674
+ if slot not in self._retired:
675
+ return
676
+ self._closing.add(id(slot))
677
+ cancellation, close_error = await self._complete_cleanup(self._close_slot(slot))
678
+ finalize_cancellation, finalize_error = await self._complete_cleanup(
679
+ self._finish_tracked_close(slot, succeeded=close_error is None)
680
+ )
681
+ cancellation = cancellation or finalize_cancellation
682
+ if finalize_error is not None:
683
+ raise finalize_error from None
684
+ if close_error is not None:
685
+ raise close_error from None
686
+ if cancellation is not None:
687
+ raise cancellation from None
688
+
689
+ async def _finish_tracked_close(self, slot: _Slot, *, succeeded: bool) -> None:
690
+ async with self._condition:
691
+ if succeeded and slot in self._retired:
692
+ self._retired.remove(slot)
693
+ self._closing.discard(id(slot))
694
+ self._condition.notify_all()
695
+
696
+ async def _track_orphan(self, resource: Any) -> None:
697
+ async with self._condition:
698
+ self._orphaned.append(resource)
699
+
700
+ def _validate_browser_provider(self, provider: Any) -> None:
701
+ if provider is None:
702
+ return
703
+ from .providers.browser import CDPBrowser
704
+
705
+ if isinstance(provider, CDPBrowser):
706
+ raise ConfigurationError(
707
+ "Borrowed CDP browsers cannot be isolated as SessionPool slots"
708
+ )
709
+ if (
710
+ getattr(provider, "_dataapp_crawler_provider", None) == "relay"
711
+ and getattr(provider, "_user_data_dir", None) is not None
712
+ ):
713
+ raise ConfigurationError(
714
+ "Persistent Relay user_data_dir is unavailable in SessionPool mode"
715
+ )
716
+
717
+ async def _close_one(self, resource: Any) -> None:
718
+ if resource is None:
719
+ return
720
+ if self._close_resource is not None:
721
+ await self._close_resource(resource)
722
+ return
723
+ closer = getattr(resource, "close", None) or getattr(resource, "aclose", None)
724
+ if not callable(closer):
725
+ raise TypeError("Session resource exposes no close method")
726
+ await _resolve(closer())
727
+
728
+ async def _close_orphan(self, resource: Any) -> None:
729
+ await self._close_one(resource)
730
+ async with self._condition:
731
+ self._orphaned = [item for item in self._orphaned if item is not resource]
732
+
733
+
734
+ async def _resolve(value: Any) -> Any:
735
+ return await value if inspect.isawaitable(value) else value