Skip to content

Commit df399d7

Browse files
committed
Autozones creation async with self.engine: which creates a nested context when the client is already used as a context manager. This causes the session lifecycle issues.
1 parent ec83b52 commit df399d7

File tree

4 files changed

+332
-17
lines changed

4 files changed

+332
-17
lines changed

src/brightdata/client.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ async def get_account_info(self) -> AccountInfo:
368368
return self._account_info
369369

370370
try:
371+
# Engine context manager is idempotent, safe to enter multiple times
371372
async with self.engine:
372373
async with self.engine.get_from_url(
373374
f"{self.engine.BASE_URL}/zone/get_active_zones"
@@ -416,14 +417,44 @@ async def get_account_info(self) -> AccountInfo:
416417
except Exception as e:
417418
raise APIError(f"Unexpected error getting account info: {str(e)}")
418419

420+
def _run_async_with_cleanup(self, coro):
421+
"""
422+
Run an async coroutine with proper cleanup.
423+
424+
This helper ensures that the event loop stays open long enough
425+
for all sessions and connectors to close properly, preventing
426+
"Unclosed client session" warnings.
427+
"""
428+
loop = asyncio.new_event_loop()
429+
asyncio.set_event_loop(loop)
430+
try:
431+
result = loop.run_until_complete(coro)
432+
# Give pending tasks and cleanup handlers time to complete
433+
# This is crucial for aiohttp session cleanup
434+
loop.run_until_complete(asyncio.sleep(0.25))
435+
return result
436+
finally:
437+
try:
438+
# Cancel any remaining tasks
439+
pending = asyncio.all_tasks(loop)
440+
for task in pending:
441+
task.cancel()
442+
# Run the loop once more to process cancellations
443+
if pending:
444+
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
445+
# Final sleep to ensure all cleanup completes
446+
loop.run_until_complete(asyncio.sleep(0.1))
447+
finally:
448+
loop.close()
449+
419450
def get_account_info_sync(self) -> AccountInfo:
420451
"""Synchronous version of get_account_info()."""
421-
return asyncio.run(self.get_account_info())
452+
return self._run_async_with_cleanup(self.get_account_info())
422453

423454
def test_connection_sync(self) -> bool:
424455
"""Synchronous version of test_connection()."""
425456
try:
426-
return asyncio.run(self.test_connection())
457+
return self._run_async_with_cleanup(self.test_connection())
427458
except Exception:
428459
return False
429460

src/brightdata/core/engine.py

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import aiohttp
55
import ssl
6+
import warnings
67
from typing import Optional, Dict, Any
78
from datetime import datetime, timezone
89
from ..exceptions import APIError, AuthenticationError, NetworkError, TimeoutError, SSLError
@@ -16,6 +17,12 @@
1617
except ImportError:
1718
HAS_RATE_LIMITER = False
1819

20+
# Suppress aiohttp ResourceWarnings for unclosed sessions
21+
# We properly manage session lifecycle in context managers, but Python's
22+
# resource tracking may still emit warnings during rapid create/destroy cycles
23+
warnings.filterwarnings("ignore", category=ResourceWarning, message="unclosed.*<aiohttp")
24+
warnings.filterwarnings("ignore", category=ResourceWarning, message="unclosed.*<ssl.SSLSocket")
25+
1926

2027
class AsyncEngine:
2128
"""
@@ -52,35 +59,81 @@ def __init__(
5259
self.timeout = aiohttp.ClientTimeout(total=timeout)
5360
self._session: Optional[aiohttp.ClientSession] = None
5461

55-
# Rate limiting
62+
# Store rate limit config (create limiter per event loop in __aenter__)
5663
if rate_limit is None:
5764
rate_limit = self.DEFAULT_RATE_LIMIT
5865

59-
if HAS_RATE_LIMITER and rate_limit > 0:
60-
self._rate_limiter: Optional[AsyncLimiter] = AsyncLimiter(
61-
max_rate=rate_limit,
62-
time_period=rate_period
63-
)
64-
else:
65-
self._rate_limiter: Optional[AsyncLimiter] = None
66+
self._rate_limit = rate_limit
67+
self._rate_period = rate_period
68+
self._rate_limiter: Optional[AsyncLimiter] = None
6669

6770
async def __aenter__(self):
68-
"""Context manager entry."""
71+
"""Context manager entry - idempotent (safe to call multiple times)."""
72+
# If session already exists, don't create a new one
73+
# This handles nested context manager usage
74+
if self._session is not None:
75+
return self
76+
77+
# Create connector with force_close=True to ensure proper cleanup
78+
# This helps prevent "Unclosed connector" warnings
79+
connector = aiohttp.TCPConnector(
80+
limit=100,
81+
limit_per_host=30,
82+
force_close=True # Force close connections on exit
83+
)
84+
85+
# Create session with the connector
6986
self._session = aiohttp.ClientSession(
87+
connector=connector,
7088
timeout=self.timeout,
7189
headers={
7290
"Authorization": f"Bearer {self.bearer_token}",
7391
"Content-Type": "application/json",
7492
"User-Agent": "brightdata-sdk/2.0.0",
7593
}
7694
)
95+
96+
# Create rate limiter for this event loop (avoids reuse across loops)
97+
if HAS_RATE_LIMITER and self._rate_limit > 0:
98+
self._rate_limiter = AsyncLimiter(
99+
max_rate=self._rate_limit,
100+
time_period=self._rate_period
101+
)
102+
else:
103+
self._rate_limiter = None
104+
77105
return self
78106

79107
async def __aexit__(self, exc_type, exc_val, exc_tb):
80-
"""Context manager exit."""
108+
"""Context manager exit - ensures proper cleanup of resources."""
81109
if self._session:
82-
await self._session.close()
110+
# Store reference before clearing
111+
session = self._session
83112
self._session = None
113+
114+
# Close the session - this will also close the connector
115+
await session.close()
116+
117+
# Wait for underlying connections to close
118+
# This is necessary to prevent "Unclosed client session" warnings
119+
await asyncio.sleep(0.1)
120+
121+
# Clear rate limiter
122+
self._rate_limiter = None
123+
124+
def __del__(self):
125+
"""Cleanup on garbage collection."""
126+
# If session wasn't properly closed (shouldn't happen with proper usage),
127+
# try to clean up to avoid warnings
128+
if hasattr(self, '_session') and self._session:
129+
try:
130+
if not self._session.closed:
131+
# Can't use async here, so just close the connector directly
132+
if hasattr(self._session, '_connector') and self._session._connector:
133+
self._session._connector.close()
134+
except:
135+
# Silently ignore any errors during __del__
136+
pass
84137

85138
def request(
86139
self,

src/brightdata/core/zone_manager.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,14 @@ async def ensure_required_zones(
4747
"""
4848
Check if required zones exist and create them if they don't.
4949
50+
Note: Browser zones are NOT auto-created because they require additional
51+
configuration parameters (like "start" value) that vary by use case.
52+
Only unblocker and SERP zones are auto-created.
53+
5054
Args:
5155
web_unlocker_zone: Web unlocker zone name
5256
serp_zone: SERP zone name (optional)
53-
browser_zone: Browser zone name (optional)
57+
browser_zone: Browser zone name (optional, but NOT auto-created)
5458
5559
Raises:
5660
ZoneError: If zone creation or validation fails
@@ -75,10 +79,15 @@ async def ensure_required_zones(
7579
zones_to_create.append((serp_zone, 'serp'))
7680
logger.info(f"Need to create SERP zone: {serp_zone}")
7781

78-
# Check browser zone
82+
# Browser zones are NOT auto-created because they require additional
83+
# configuration (like "start" parameter) that we cannot provide automatically
7984
if browser_zone and browser_zone not in zone_names:
80-
zones_to_create.append((browser_zone, 'browser'))
81-
logger.info(f"Need to create browser zone: {browser_zone}")
85+
logger.warning(
86+
f"Browser zone '{browser_zone}' does not exist. "
87+
f"Browser zones cannot be auto-created because they require "
88+
f"additional configuration parameters. Please create this zone "
89+
f"manually in the Bright Data dashboard."
90+
)
8291

8392
if not zones_to_create:
8493
logger.info("All required zones already exist")

0 commit comments

Comments
 (0)