diff --git a/openapi_python_sdk/async_client.py b/openapi_python_sdk/async_client.py index fd6af56..61c7ee9 100644 --- a/openapi_python_sdk/async_client.py +++ b/openapi_python_sdk/async_client.py @@ -1,4 +1,6 @@ +import asyncio import json +import random from typing import Any, Dict import httpx @@ -10,8 +12,21 @@ class AsyncClient: Suitable for use with FastAPI, aiohttp, etc. """ - def __init__(self, token: str, client: Any = None, timeout: float = 30.0): + def __init__( + self, + token: str, + client: Any = None, + timeout: float = 30.0, + max_retries: int = 0, + backoff_factor: float = 1.0, + retry_on_status: list[int] = None, + ): self.client = client if client is not None else httpx.AsyncClient(timeout=timeout) + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.retry_on_status = ( + retry_on_status if retry_on_status is not None else [429, 502, 503, 504] + ) self.auth_header: str = f"Bearer {token}" self.headers: Dict[str, str] = { "Authorization": self.auth_header, @@ -30,6 +45,32 @@ async def aclose(self): """Manually close the underlying HTTP client (async).""" await self.client.aclose() + async def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response: + attempts = 0 + while True: + try: + resp = await request_fn(*args, **kwargs) + if resp.status_code in self.retry_on_status and attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + if resp.status_code == 429: + retry_after = resp.headers.get("Retry-After") + if retry_after: + try: + sleep_time = float(retry_after) + except ValueError: + pass + await asyncio.sleep(sleep_time) + continue + return resp + except httpx.RequestError as exc: + if attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + await asyncio.sleep(sleep_time) + continue + raise exc + async def request( self, method: str = "GET", @@ -50,7 +91,8 @@ async def request( url = f"{url}&{query_string}" if "?" in url else f"{url}?{query_string}" params = None - resp = await self.client.request( + resp = await self._request_with_retry( + self.client.request, method=method, url=url, headers=self.headers, diff --git a/openapi_python_sdk/async_oauth_client.py b/openapi_python_sdk/async_oauth_client.py index 1de3fbd..ad07349 100644 --- a/openapi_python_sdk/async_oauth_client.py +++ b/openapi_python_sdk/async_oauth_client.py @@ -1,4 +1,6 @@ +import asyncio import base64 +import random from typing import Any, Dict, List import httpx @@ -12,8 +14,23 @@ class AsyncOauthClient: Suitable for use with FastAPI, aiohttp, etc. """ - def __init__(self, username: str, apikey: str, test: bool = False, client: Any = None, timeout: float = 30.0): + def __init__( + self, + username: str, + apikey: str, + test: bool = False, + client: Any = None, + timeout: float = 30.0, + max_retries: int = 0, + backoff_factor: float = 1.0, + retry_on_status: List[int] = None, + ): self.client = client if client is not None else httpx.AsyncClient(timeout=timeout) + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.retry_on_status = ( + retry_on_status if retry_on_status is not None else [429, 502, 503, 504] + ) self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL self.auth_header: str = ( "Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode() @@ -35,35 +52,61 @@ async def aclose(self): """Manually close the underlying HTTP client (async).""" await self.client.aclose() + async def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response: + attempts = 0 + while True: + try: + resp = await request_fn(*args, **kwargs) + if resp.status_code in self.retry_on_status and attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + if resp.status_code == 429: + retry_after = resp.headers.get("Retry-After") + if retry_after: + try: + sleep_time = float(retry_after) + except ValueError: + pass + await asyncio.sleep(sleep_time) + continue + return resp + except httpx.RequestError as exc: + if attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + await asyncio.sleep(sleep_time) + continue + raise exc + async def get_scopes(self, limit: bool = False) -> Dict[str, Any]: """Retrieve available scopes for the current user (async).""" params = {"limit": int(limit)} url = f"{self.url}/scopes" - resp = await self.client.get(url=url, headers=self.headers, params=params) + resp = await self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params) return resp.json() async def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]: """Create a new bearer token with specified scopes and TTL (async).""" payload = {"scopes": scopes, "ttl": ttl} url = f"{self.url}/token" - resp = await self.client.post(url=url, headers=self.headers, json=payload) + resp = await self._request_with_retry(self.client.post, url=url, headers=self.headers, json=payload) return resp.json() async def get_token(self, scope: str = None) -> Dict[str, Any]: """Retrieve an existing token, optionally filtered by scope (async).""" params = {"scope": scope or ""} url = f"{self.url}/token" - resp = await self.client.get(url=url, headers=self.headers, params=params) + resp = await self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params) return resp.json() async def delete_token(self, id: str) -> Dict[str, Any]: """Revoke/Delete a specific token by ID (async).""" url = f"{self.url}/token/{id}" - resp = await self.client.delete(url=url, headers=self.headers) + resp = await self._request_with_retry(self.client.delete, url=url, headers=self.headers) return resp.json() async def get_counters(self, period: str, date: str) -> Dict[str, Any]: """Retrieve usage counters for a specific period and date (async).""" url = f"{self.url}/counters/{period}/{date}" - resp = await self.client.get(url=url, headers=self.headers) + resp = await self._request_with_retry(self.client.get, url=url, headers=self.headers) return resp.json() diff --git a/openapi_python_sdk/client.py b/openapi_python_sdk/client.py index 4ba5211..159c0c0 100644 --- a/openapi_python_sdk/client.py +++ b/openapi_python_sdk/client.py @@ -1,5 +1,7 @@ import json +import random import threading +import time from typing import Any, Dict import httpx @@ -15,10 +17,23 @@ class Client: Synchronous client for making authenticated requests to Openapi endpoints. """ - def __init__(self, token: str, client: Any = None, timeout: float = 30.0): + def __init__( + self, + token: str, + client: Any = None, + timeout: float = 30.0, + max_retries: int = 0, + backoff_factor: float = 1.0, + retry_on_status: list[int] = None, + ): self._client = client self._thread_local = threading.local() self.timeout = timeout + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.retry_on_status = ( + retry_on_status if retry_on_status is not None else [429, 502, 503, 504] + ) self.auth_header: str = f"Bearer {token}" self.headers: Dict[str, str] = { "Authorization": self.auth_header, @@ -55,6 +70,32 @@ def close(self): """Manually close the underlying HTTP client.""" self.client.close() + def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response: + attempts = 0 + while True: + try: + resp = request_fn(*args, **kwargs) + if resp.status_code in self.retry_on_status and attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + if resp.status_code == 429: + retry_after = resp.headers.get("Retry-After") + if retry_after: + try: + sleep_time = float(retry_after) + except ValueError: + pass + time.sleep(sleep_time) + continue + return resp + except httpx.RequestError as exc: + if attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + time.sleep(sleep_time) + continue + raise exc + def request( self, method: str = "GET", @@ -75,13 +116,15 @@ def request( url = f"{url}&{query_string}" if "?" in url else f"{url}?{query_string}" params = None - data = self.client.request( + resp = self._request_with_retry( + self.client.request, method=method, url=url, headers=self.headers, json=payload, params=params, - ).json() + ) + data = resp.json() # Handle cases where the API might return a JSON-encoded string instead of an object if isinstance(data, str): diff --git a/openapi_python_sdk/oauth_client.py b/openapi_python_sdk/oauth_client.py index a3be9db..cd39d45 100644 --- a/openapi_python_sdk/oauth_client.py +++ b/openapi_python_sdk/oauth_client.py @@ -1,5 +1,7 @@ import base64 +import random import threading +import time from typing import Any, Dict, List import httpx @@ -13,10 +15,25 @@ class OauthClient: Synchronous client for handling Openapi authentication and token management. """ - def __init__(self, username: str, apikey: str, test: bool = False, client: Any = None, timeout: float = 30.0): + def __init__( + self, + username: str, + apikey: str, + test: bool = False, + client: Any = None, + timeout: float = 30.0, + max_retries: int = 0, + backoff_factor: float = 1.0, + retry_on_status: List[int] = None, + ): self._client = client self._thread_local = threading.local() self.timeout = timeout + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.retry_on_status = ( + retry_on_status if retry_on_status is not None else [429, 502, 503, 504] + ) self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL self.auth_header: str = ( "Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode() @@ -55,30 +72,61 @@ def close(self): """Manually close the underlying HTTP client.""" self.client.close() + def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response: + attempts = 0 + while True: + try: + resp = request_fn(*args, **kwargs) + if resp.status_code in self.retry_on_status and attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + if resp.status_code == 429: + retry_after = resp.headers.get("Retry-After") + if retry_after: + try: + sleep_time = float(retry_after) + except ValueError: + pass + time.sleep(sleep_time) + continue + return resp + except httpx.RequestError as exc: + if attempts < self.max_retries: + attempts += 1 + sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5) + time.sleep(sleep_time) + continue + raise exc + def get_scopes(self, limit: bool = False) -> Dict[str, Any]: """Retrieve available scopes for the current user.""" params = {"limit": int(limit)} url = f"{self.url}/scopes" - return self.client.get(url=url, headers=self.headers, params=params).json() + resp = self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params) + return resp.json() def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]: """Create a new bearer token with specified scopes and TTL.""" payload = {"scopes": scopes, "ttl": ttl} url = f"{self.url}/token" - return self.client.post(url=url, headers=self.headers, json=payload).json() + resp = self._request_with_retry(self.client.post, url=url, headers=self.headers, json=payload) + return resp.json() def get_token(self, scope: str = None) -> Dict[str, Any]: """Retrieve an existing token, optionally filtered by scope.""" params = {"scope": scope or ""} url = f"{self.url}/token" - return self.client.get(url=url, headers=self.headers, params=params).json() + resp = self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params) + return resp.json() def delete_token(self, id: str) -> Dict[str, Any]: """Revoke/Delete a specific token by ID.""" url = f"{self.url}/token/{id}" - return self.client.delete(url=url, headers=self.headers).json() + resp = self._request_with_retry(self.client.delete, url=url, headers=self.headers) + return resp.json() def get_counters(self, period: str, date: str) -> Dict[str, Any]: """Retrieve usage counters for a specific period and date.""" url = f"{self.url}/counters/{period}/{date}" - return self.client.get(url=url, headers=self.headers).json() + resp = self._request_with_retry(self.client.get, url=url, headers=self.headers) + return resp.json() diff --git a/tests/test_async_retry.py b/tests/test_async_retry.py new file mode 100644 index 0000000..a09f666 --- /dev/null +++ b/tests/test_async_retry.py @@ -0,0 +1,158 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from openapi_python_sdk.client import AsyncClient, AsyncOauthClient + + +class TestAsyncRetry(unittest.IsolatedAsyncioTestCase): + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_default_no_retries(self, mock_sleep, mock_httpx): + """Verify that max_retries defaults to 0 and does not retry on network error.""" + # Setup mock to raise a request error + mock_httpx.return_value.request = AsyncMock(side_effect=httpx.RequestError("Network error")) + mock_httpx.return_value.aclose = AsyncMock() + + async with AsyncClient(token="test_token") as client: + self.assertEqual(client.max_retries, 0) + with self.assertRaises(httpx.RequestError): + await client.request(method="GET", url="https://test.example.com") + + # Request should only be called once + self.assertEqual(mock_httpx.return_value.request.call_count, 1) + mock_sleep.assert_not_called() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_successful_request_no_retries(self, mock_sleep, mock_httpx): + """Verify that a successful request is only called once even if max_retries > 0.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "ok"} + mock_httpx.return_value.request = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + async with AsyncClient(token="test_token", max_retries=3, backoff_factor=0.001) as client: + resp = await client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + self.assertEqual(mock_httpx.return_value.request.call_count, 1) + mock_sleep.assert_not_called() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_retry_on_network_error_then_success(self, mock_sleep, mock_httpx): + """Verify client retries on network error and succeeds on subsequent try.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "ok"} + mock_httpx.return_value.aclose = AsyncMock() + + # First two requests fail, third succeeds + mock_httpx.return_value.request = AsyncMock(side_effect=[ + httpx.RequestError("Error 1"), + httpx.RequestError("Error 2"), + mock_resp, + ]) + + async with AsyncClient(token="test_token", max_retries=3, backoff_factor=0.1) as client: + resp = await client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + self.assertEqual(mock_httpx.return_value.request.call_count, 3) + self.assertEqual(mock_sleep.call_count, 2) + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_retry_limit_reached(self, mock_sleep, mock_httpx): + """Verify that client fails after reaching max_retries limit.""" + mock_httpx.return_value.request = AsyncMock(side_effect=httpx.RequestError("Error")) + mock_httpx.return_value.aclose = AsyncMock() + + async with AsyncClient(token="test_token", max_retries=2, backoff_factor=0.1) as client: + with self.assertRaises(httpx.RequestError): + await client.request(method="GET", url="https://test.example.com") + + # Initial call + 2 retries = 3 calls + self.assertEqual(mock_httpx.return_value.request.call_count, 3) + self.assertEqual(mock_sleep.call_count, 2) + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_retry_on_status_codes(self, mock_sleep, mock_httpx): + """Verify client retries on configured retry status codes (e.g. 503).""" + mock_resp_fail = MagicMock() + mock_resp_fail.status_code = 503 + + mock_resp_success = MagicMock() + mock_resp_success.status_code = 200 + mock_resp_success.json.return_value = {"status": "ok"} + mock_httpx.return_value.aclose = AsyncMock() + + mock_httpx.return_value.request = AsyncMock(side_effect=[ + mock_resp_fail, + mock_resp_success, + ]) + + async with AsyncClient(token="test_token", max_retries=3, backoff_factor=0.1) as client: + resp = await client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + self.assertEqual(mock_httpx.return_value.request.call_count, 2) + mock_sleep.assert_called_once() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_respects_retry_after_header(self, mock_sleep, mock_httpx): + """Verify client respects Retry-After header on 429 Too Many Requests.""" + mock_resp_429 = MagicMock() + mock_resp_429.status_code = 429 + mock_resp_429.headers = {"Retry-After": "5"} + + mock_resp_success = MagicMock() + mock_resp_success.status_code = 200 + mock_resp_success.json.return_value = {"status": "ok"} + mock_httpx.return_value.aclose = AsyncMock() + + mock_httpx.return_value.request = AsyncMock(side_effect=[ + mock_resp_429, + mock_resp_success, + ]) + + async with AsyncClient(token="test_token", max_retries=3, backoff_factor=1.0) as client: + resp = await client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + # Should sleep for exactly 5.0 seconds as specified by Retry-After + mock_sleep.assert_called_once_with(5.0) + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + @patch("asyncio.sleep") + async def test_oauth_client_retry(self, mock_sleep, mock_httpx): + """Verify that AsyncOauthClient also supports retries.""" + mock_resp_fail = MagicMock() + mock_resp_fail.status_code = 502 + + mock_resp_success = MagicMock() + mock_resp_success.status_code = 200 + mock_resp_success.json.return_value = {"token": "abc123"} + mock_httpx.return_value.aclose = AsyncMock() + + mock_httpx.return_value.post = AsyncMock(side_effect=[ + mock_resp_fail, + mock_resp_success, + ]) + + async with AsyncOauthClient(username="user", apikey="key", max_retries=2, backoff_factor=0.1) as oauth: + resp = await oauth.create_token(scopes=["test"]) + + self.assertEqual(resp["token"], "abc123") + self.assertEqual(mock_httpx.return_value.post.call_count, 2) + mock_sleep.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..c2b1a9e --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,152 @@ +import unittest +from unittest.mock import MagicMock, patch + +import httpx + +from openapi_python_sdk.client import Client, OauthClient + + +class TestSyncRetry(unittest.TestCase): + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_default_no_retries(self, mock_sleep, mock_httpx): + """Verify that max_retries defaults to 0 and does not retry on network error.""" + # Setup mock to raise a request error + mock_httpx.return_value.request.side_effect = httpx.RequestError("Network error") + + client = Client(token="test_token") + self.assertEqual(client.max_retries, 0) + + with self.assertRaises(httpx.RequestError): + client.request(method="GET", url="https://test.example.com") + + # Request should only be called once + self.assertEqual(mock_httpx.return_value.request.call_count, 1) + mock_sleep.assert_not_called() + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_successful_request_no_retries(self, mock_sleep, mock_httpx): + """Verify that a successful request is only called once even if max_retries > 0.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "ok"} + mock_httpx.return_value.request.return_value = mock_resp + + client = Client(token="test_token", max_retries=3, backoff_factor=0.001) + resp = client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + self.assertEqual(mock_httpx.return_value.request.call_count, 1) + mock_sleep.assert_not_called() + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_retry_on_network_error_then_success(self, mock_sleep, mock_httpx): + """Verify client retries on network error and succeeds on subsequent try.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "ok"} + + # First two requests fail, third succeeds + mock_httpx.return_value.request.side_effect = [ + httpx.RequestError("Error 1"), + httpx.RequestError("Error 2"), + mock_resp, + ] + + client = Client(token="test_token", max_retries=3, backoff_factor=0.1) + resp = client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + self.assertEqual(mock_httpx.return_value.request.call_count, 3) + self.assertEqual(mock_sleep.call_count, 2) + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_retry_limit_reached(self, mock_sleep, mock_httpx): + """Verify that client fails after reaching max_retries limit.""" + mock_httpx.return_value.request.side_effect = httpx.RequestError("Error") + + client = Client(token="test_token", max_retries=2, backoff_factor=0.1) + with self.assertRaises(httpx.RequestError): + client.request(method="GET", url="https://test.example.com") + + # Initial call + 2 retries = 3 calls + self.assertEqual(mock_httpx.return_value.request.call_count, 3) + self.assertEqual(mock_sleep.call_count, 2) + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_retry_on_status_codes(self, mock_sleep, mock_httpx): + """Verify client retries on configured retry status codes (e.g. 503).""" + mock_resp_fail = MagicMock() + mock_resp_fail.status_code = 503 + + mock_resp_success = MagicMock() + mock_resp_success.status_code = 200 + mock_resp_success.json.return_value = {"status": "ok"} + + mock_httpx.return_value.request.side_effect = [ + mock_resp_fail, + mock_resp_success, + ] + + client = Client(token="test_token", max_retries=3, backoff_factor=0.1) + resp = client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + self.assertEqual(mock_httpx.return_value.request.call_count, 2) + mock_sleep.assert_called_once() + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_respects_retry_after_header(self, mock_sleep, mock_httpx): + """Verify client respects Retry-After header on 429 Too Many Requests.""" + mock_resp_429 = MagicMock() + mock_resp_429.status_code = 429 + mock_resp_429.headers = {"Retry-After": "5"} + + mock_resp_success = MagicMock() + mock_resp_success.status_code = 200 + mock_resp_success.json.return_value = {"status": "ok"} + + mock_httpx.return_value.request.side_effect = [ + mock_resp_429, + mock_resp_success, + ] + + client = Client(token="test_token", max_retries=3, backoff_factor=1.0) + resp = client.request(method="GET", url="https://test.example.com") + + self.assertEqual(resp, {"status": "ok"}) + # Should sleep for exactly 5.0 seconds as specified by Retry-After + mock_sleep.assert_called_once_with(5.0) + + @patch("openapi_python_sdk.client.httpx.Client") + @patch("time.sleep") + def test_oauth_client_retry(self, mock_sleep, mock_httpx): + """Verify that OauthClient also supports retries.""" + mock_resp_fail = MagicMock() + mock_resp_fail.status_code = 502 + + mock_resp_success = MagicMock() + mock_resp_success.status_code = 200 + mock_resp_success.json.return_value = {"token": "abc123"} + + mock_httpx.return_value.post.side_effect = [ + mock_resp_fail, + mock_resp_success, + ] + + oauth = OauthClient(username="user", apikey="key", max_retries=2, backoff_factor=0.1) + resp = oauth.create_token(scopes=["test"]) + + self.assertEqual(resp["token"], "abc123") + self.assertEqual(mock_httpx.return_value.post.call_count, 2) + mock_sleep.assert_called_once() + + +if __name__ == "__main__": + unittest.main()