From 1ff99524c10b1961693d81f90d4c8ef452ff4dc9 Mon Sep 17 00:00:00 2001 From: Logan Gore Date: Mon, 4 May 2026 10:09:10 -0700 Subject: [PATCH 1/2] Add support for async close on http client --- .gitignore | 1 + stytch/core/client_base.py | 9 ++++++++ stytch/core/http/client.py | 6 +++++ stytch/core/http/test/test_client.py | 20 +++++++++++++++++ stytch/core/test/test_client_base.py | 33 +++++++++++++++++++++++++++- 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7ffb9326..0e273bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ stytch.egg-info/ .coverage dist/ .idea/ +.worktrees/ diff --git a/stytch/core/client_base.py b/stytch/core/client_base.py index ba75f2df..ff12894c 100644 --- a/stytch/core/client_base.py +++ b/stytch/core/client_base.py @@ -35,6 +35,15 @@ def __init__( self.async_client = AsyncClient(project_id, secret, session=async_session) self.jwks_client = self.get_jwks_client(project_id) + async def close(self) -> None: + await self.async_client.close() + + async def __aenter__(self) -> "ClientBase": + return self + + async def __aexit__(self, *_: object) -> Optional[bool]: + await self.close() + @abc.abstractmethod def get_jwks_client(self, project_id: str) -> jwt.PyJWKClient: pass diff --git a/stytch/core/http/client.py b/stytch/core/http/client.py index 774ae4f4..cafc4ad1 100644 --- a/stytch/core/http/client.py +++ b/stytch/core/http/client.py @@ -146,6 +146,12 @@ def __del__(self) -> None: except Exception: pass + async def close(self) -> None: + if self._external_session or self.__session is None: + return + await self.__session.close() + self.__session = None + @classmethod async def _response_from_request( cls, r: aiohttp.ClientResponse diff --git a/stytch/core/http/test/test_client.py b/stytch/core/http/test/test_client.py index 739f1b67..76410741 100644 --- a/stytch/core/http/test/test_client.py +++ b/stytch/core/http/test/test_client.py @@ -5,6 +5,7 @@ import asyncio import unittest +import aiohttp import requests from stytch.core.http.client import AsyncClient, SyncClient @@ -31,3 +32,22 @@ def test_session_without_event_loop(self): asyncio.set_event_loop_policy(NoEventLoopPolicy()) client = AsyncClient("project_id", "secret") self.assertIsNotNone(client) + + +class TestAsyncClientClose(unittest.IsolatedAsyncioTestCase): + async def test_close_owned_session(self): + client = AsyncClient("project_id", "secret") + session = client._session # force session creation and capture reference + await client.close() + self.assertTrue(session.closed) + + async def test_close_external_session_is_noop(self): + async with aiohttp.ClientSession() as external: + client = AsyncClient("project_id", "secret", session=external) + await client.close() + self.assertFalse(external.closed) + + async def test_close_before_session_created_is_noop(self): + client = AsyncClient("project_id", "secret") + # no session accessed — should not raise + await client.close() diff --git a/stytch/core/test/test_client_base.py b/stytch/core/test/test_client_base.py index a2a5be43..c0eefaba 100644 --- a/stytch/core/test/test_client_base.py +++ b/stytch/core/test/test_client_base.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 +import unittest import warnings from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pydantic @@ -155,3 +156,33 @@ def test_resolve_api_url_requires_https(self): # Verify the error message self.assertIn("HTTPS scheme", str(context.exception)) + + +class AsyncClientLifecycleTests(unittest.IsolatedAsyncioTestCase): + def _make_client(self): + return Client( + project_id="project-test-00000000-0000-0000-0000-000000000000", + secret="secret", + suppress_warnings=True, + ) + + async def test_close_delegates_to_async_client(self): + client = self._make_client() + client.async_client.close = AsyncMock() + await client.close() + client.async_client.close.assert_awaited_once() + + async def test_context_manager_calls_close(self): + client = self._make_client() + client.async_client.close = AsyncMock() + async with client as c: + self.assertIs(c, client) + client.async_client.close.assert_awaited_once() + + async def test_context_manager_calls_close_on_exception(self): + client = self._make_client() + client.async_client.close = AsyncMock() + with self.assertRaises(ValueError): + async with client: + raise ValueError("boom") + client.async_client.close.assert_awaited_once() From b319cb49014871312aaf89b788eab4f492d6b97e Mon Sep 17 00:00:00 2001 From: Logan Gore Date: Mon, 4 May 2026 10:09:40 -0700 Subject: [PATCH 2/2] Bump version --- stytch/core/client_base.py | 1 + stytch/version.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/stytch/core/client_base.py b/stytch/core/client_base.py index ff12894c..027de637 100644 --- a/stytch/core/client_base.py +++ b/stytch/core/client_base.py @@ -43,6 +43,7 @@ async def __aenter__(self) -> "ClientBase": async def __aexit__(self, *_: object) -> Optional[bool]: await self.close() + return None @abc.abstractmethod def get_jwks_client(self, project_id: str) -> jwt.PyJWKClient: diff --git a/stytch/version.py b/stytch/version.py index 9a75d436..30c70d39 100644 --- a/stytch/version.py +++ b/stytch/version.py @@ -1 +1 @@ -__version__ = "15.1.0" +__version__ = "15.2.0"