Skip to content

Commit eafeb70

Browse files
authored
improve async client shutdown functionality (#31)
* improve async client shutdown functionality * initialize "initialized" to false until initialized
1 parent 67f452a commit eafeb70

2 files changed

Lines changed: 175 additions & 19 deletions

File tree

README.md

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,102 @@ client = Schematic("YOUR_API_KEY")
2727
```
2828

2929
## Async Client
30-
The SDK also exports an async client so that you can make non-blocking calls to our API.
30+
31+
The SDK exports an async client for non-blocking API calls with automatic background event processing. The async client features **lazy initialization** - you can start using it immediately without manual setup.
32+
33+
### Simple Usage (Lazy Initialization)
34+
35+
The easiest way to use the async client - just create and use it directly:
3136

3237
```python
38+
import asyncio
3339
from schematic.client import AsyncSchematic
3440

35-
client = AsyncSchematic("YOUR_API_KEY")
36-
async def main() -> None:
37-
await client.companies.get_company(
38-
company_id="company_id",
41+
async def main():
42+
# Create client - no initialize() needed!
43+
client = AsyncSchematic("YOUR_API_KEY")
44+
45+
# Use immediately - auto-initializes on first call
46+
is_enabled = await client.check_flag(
47+
"new-feature",
48+
company={"id": "company-123"},
49+
user={"id": "user-456"}
3950
)
51+
52+
if is_enabled:
53+
print("New feature is enabled!")
54+
55+
# Track usage
56+
await client.track(
57+
event="feature-used",
58+
company={"id": "company-123"},
59+
user={"id": "user-456"}
60+
)
61+
62+
# Always shutdown when done
63+
await client.shutdown()
4064

4165
asyncio.run(main())
4266
```
4367

68+
### Context Manager (Recommended)
69+
70+
Use the async client as a context manager for automatic lifecycle management:
71+
72+
```python
73+
import asyncio
74+
from schematic.client import AsyncSchematic
75+
76+
async def main():
77+
async with AsyncSchematic("YOUR_API_KEY") as client:
78+
# Client auto-initializes and will auto-shutdown
79+
80+
is_enabled = await client.check_flag(
81+
"feature-flag",
82+
company={"id": "company-123"}
83+
)
84+
85+
await client.identify(
86+
keys={"id": "company-123"},
87+
name="Acme Corp"
88+
)
89+
90+
# Automatic cleanup on exit
91+
92+
asyncio.run(main())
93+
```
94+
95+
### Production Usage (Explicit Control)
96+
97+
For production applications that need precise control over initialization timing:
98+
99+
```python
100+
import asyncio
101+
from schematic.client import AsyncSchematic
102+
103+
# Web application example
104+
client = AsyncSchematic("YOUR_API_KEY")
105+
106+
async def startup():
107+
"""Call during application startup"""
108+
await client.initialize() # Start background tasks now
109+
print("Schematic client ready")
110+
111+
async def shutdown():
112+
"""Call during application shutdown"""
113+
await client.shutdown() # Stop background tasks and flush events
114+
print("Schematic client stopped")
115+
116+
async def handle_request():
117+
"""Handle individual requests"""
118+
# Client is already initialized - this will be fast
119+
is_enabled = await client.check_flag(
120+
"feature-flag",
121+
company={"id": "company-123"}
122+
)
123+
return {"feature_enabled": is_enabled}
124+
```
125+
44126
## Exception Handling
45127
All errors thrown by the SDK will be subclasses of [`ApiError`](./src/schematic/core/api_error.py).
46128

@@ -106,7 +188,23 @@ client.track(
106188
)
107189
```
108190

109-
This call is non-blocking and there is no response to check.
191+
**Async client:**
192+
```python
193+
import asyncio
194+
from schematic.client import AsyncSchematic
195+
196+
async def main():
197+
async with AsyncSchematic("YOUR_API_KEY") as client:
198+
await client.track(
199+
event="some-action",
200+
user={"user_id": "your-user-id"},
201+
company={"id": "your-company-id"},
202+
)
203+
204+
asyncio.run(main())
205+
```
206+
207+
These calls are non-blocking and there is no response to check.
110208

111209
If you want to record large numbers of the same event at once, or perhaps measure usage in terms of a unit like tokens or memory, you can optionally specify a quantity for your event:
112210

src/schematic/client.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import asyncio
21
import atexit
32
import logging
4-
import signal
53
from dataclasses import dataclass
64
from typing import Any, Dict, List, Optional
75

@@ -163,7 +161,34 @@ class AsyncSchematicConfig:
163161

164162

165163
class AsyncSchematic(AsyncBaseSchematic):
164+
"""Async Schematic client for feature flags and event tracking.
165+
166+
This client provides async methods for checking feature flags and tracking events.
167+
It automatically initializes on first use and maintains background tasks for
168+
event buffering that require proper cleanup.
169+
170+
IMPORTANT: Always call shutdown() when done, or use as a context manager:
171+
172+
# Recommended patterns:
173+
174+
# 1. Context manager (automatic cleanup):
175+
async with AsyncSchematic(api_key, config) as client:
176+
result = await client.check_flag("my-flag") # Auto-initializes
177+
178+
# 2. Manual (explicit cleanup):
179+
client = AsyncSchematic(api_key, config)
180+
try:
181+
result = await client.check_flag("my-flag") # Auto-initializes
182+
finally:
183+
await client.shutdown() # REQUIRED for proper cleanup
184+
185+
# 3. Web framework (lifecycle managed):
186+
# In startup: client = AsyncSchematic(api_key, config)
187+
# In shutdown: await client.shutdown()
188+
"""
189+
166190
def __init__(self, api_key: str, config: Optional[AsyncSchematicConfig] = None):
191+
self._initialized = False
167192
config = config or AsyncSchematicConfig()
168193
httpx_client = (
169194
AsyncOfflineHTTPClient() if config.offline else config.httpx_client
@@ -188,11 +213,18 @@ def __init__(self, api_key: str, config: Optional[AsyncSchematicConfig] = None):
188213
LocalCache[bool](DEFAULT_CACHE_SIZE, DEFAULT_CACHE_TTL)
189214
]
190215
self.offline = config.offline
191-
for sig in (signal.SIGINT, signal.SIGTERM):
192-
signal.signal(sig, self._shutdown_handler)
216+
self._shutdown_requested = False
217+
self._is_shutting_down = False
218+
self._initialized = True
219+
220+
async def __aenter__(self):
221+
return self
222+
223+
async def __aexit__(self, exc_type, exc_val, exc_tb):
224+
await self.shutdown()
193225

194226
async def initialize(self) -> None:
195-
pass
227+
pass
196228

197229
async def check_flag(
198230
self,
@@ -233,7 +265,7 @@ async def identify(
233265
company: Optional[EventBodyIdentifyCompany] = None,
234266
name: Optional[str] = None,
235267
traits: Optional[Dict[str, Any]] = None,
236-
) -> None:
268+
) -> None:
237269
await self._enqueue_event(
238270
"identify",
239271
EventBodyIdentify(
@@ -251,7 +283,7 @@ async def track(
251283
user: Optional[Dict[str, str]] = None,
252284
traits: Optional[Dict[str, Any]] = None,
253285
quantity: Optional[int] = None,
254-
) -> None:
286+
) -> None:
255287
await self._enqueue_event(
256288
"track",
257289
EventBodyTrack(
@@ -275,10 +307,36 @@ async def _enqueue_event(self, event_type: str, body: EventBody) -> None:
275307
def _get_flag_default(self, flag_key: str) -> bool:
276308
return self.flag_defaults.get(flag_key, False)
277309

278-
def _shutdown_handler(self, signum, frame):
279-
self.logger.info(f"Received signal {signum}. Initiating shutdown.")
280-
asyncio.create_task(self.shutdown())
281-
282310
async def shutdown(self) -> None:
283-
await self.event_buffer.stop()
284-
self.logger.info("Shutdown complete.")
311+
"""Properly shutdown the client, flushing any pending events.
312+
313+
This method should be called when you're done using the client to ensure:
314+
- All pending events are flushed to the server
315+
- Background tasks are properly terminated
316+
- Resources are cleaned up
317+
318+
It's safe to call this method multiple times, even if the client was never used.
319+
"""
320+
# Only do the shutdown once
321+
if self._is_shutting_down:
322+
self.logger.debug("Shutdown already in progress, skipping")
323+
return
324+
325+
self._is_shutting_down = True
326+
327+
# If we were never initialized, there's nothing to clean up
328+
if not self._initialized:
329+
self.logger.debug("Client was never initialized, nothing to clean up")
330+
return
331+
332+
self.logger.info("Shutting down AsyncSchematic...")
333+
334+
try:
335+
# Flush and stop the event buffer
336+
await self.event_buffer.stop()
337+
self.logger.info("Shutdown complete.")
338+
except Exception as e:
339+
self.logger.error(f"Error during shutdown: {e}")
340+
finally:
341+
self._shutdown_requested = True
342+

0 commit comments

Comments
 (0)