From da3adfb692ad87e7347f6b9905108281f0255c54 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 20 Feb 2026 16:58:04 +0300 Subject: [PATCH 1/7] Chore:updated the asyncClient to Match the syncClient,and updated the stk_push tests to use both session and no session in addition to 100 stk_pushes. closes#116 --- .../http_client/mpesa_async_http_client.py | 269 +++++++++++------- tests/unit/mpesa_express/test_stk_push.py | 59 +++- 2 files changed, 226 insertions(+), 102 deletions(-) diff --git a/mpesakit/http_client/mpesa_async_http_client.py b/mpesakit/http_client/mpesa_async_http_client.py index 55a5d41..5b641e5 100644 --- a/mpesakit/http_client/mpesa_async_http_client.py +++ b/mpesakit/http_client/mpesa_async_http_client.py @@ -2,9 +2,96 @@ from typing import Dict, Any, Optional import httpx +import logging from mpesakit.errors import MpesaError, MpesaApiException from .http_client import AsyncHttpClient +from urllib.parse import urljoin + +from tenacity import ( + RetryCallState, + before_sleep_log, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_random_exponential, +) + +logger = logging.getLogger(__name__) + + +def handle_request_error(response: httpx.Response): + """Handles non-successful HTTP responses. + + This function is now responsible for converting HTTP status codes + and JSON parsing errors into MpesaApiException. + """ + if response.is_success: + return + try: + response_data = response.json() + except ValueError: + response_data = {"errorMessage": response.text.strip() or ""} + + error_message = response_data.get("errorMessage", "") + raise MpesaApiException( + MpesaError( + error_code=f"HTTP_{response.status_code}", + error_message=error_message, + status_code=response.status_code, + raw_response=response_data, + ) + ) + + +def handle_retry_exception(retry_state: RetryCallState): + """Custom hook to handle exceptions after all retries fail. + + It raises a custom MpesaApiException with the appropriate error code. + """ + if retry_state.outcome: + exception = retry_state.outcome.exception() + + if isinstance(exception, httpx.TimeoutException): + raise MpesaApiException( + MpesaError(error_code="REQUEST_TIMEOUT", error_message=str(exception)) + ) from exception + elif isinstance(exception, httpx.ConnectError): + raise MpesaApiException( + MpesaError(error_code="CONNECTION_ERROR", error_message=str(exception)) + ) from exception + + raise MpesaApiException( + MpesaError(error_code="REQUEST_FAILED", error_message=str(exception)) + ) from exception + + raise MpesaApiException( + MpesaError( + error_code="REQUEST_FAILED", + error_message="An unknown retry error occurred.", + ) + ) + + +def retry_enabled(enabled: bool): + """Factory function to conditionally enable retries. + + Args: + enabled (bool): Whether to enable retry logic. + + Returns: + A retry condition function. + """ + base_retry = retry_if_exception_type( + httpx.TimeoutException + ) | retry_if_exception_type(httpx.ConnectError) + + def _retry(retry_state): + if not enabled: + return False + return base_retry(retry_state) + + return _retry class MpesaAsyncHttpClient(AsyncHttpClient): @@ -31,68 +118,78 @@ def _resolve_base_url(self, env: str) -> str: return "https://sandbox.safaricom.co.ke" - async def __aenter__(self): - return self - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self._client.aclose() + + @retry( + retry=retry_enabled(enabled=True), + wait=wait_random_exponential(multiplier=5, max=8), + stop=stop_after_attempt(3), + retry_error_callback=handle_retry_exception, + before_sleep=before_sleep_log(logger, logging.WARNING), + ) + async def async_raw_post( + self, url: str, json: Dict[str, Any], headers: Dict[str, str], timeout: int = 10 + ) -> httpx.Response: + """Low-level POST request - may raise httpx exceptions.""" + full_url = urljoin(self.base_url, url) + return await self._client.post( + full_url, json=json, headers=headers, timeout=timeout) + async def post( self, url: str, json: Dict[str, Any], headers: Dict[str, str] ) -> Dict[str, Any]: - """Sends an asynchronous POST request to the M-Pesa API.""" + """Sends a POST request to the M-Pesa API. + + Args: + url (str): The URL path for the request. + json (Dict[str, Any]): The JSON payload for the request body. + headers (Dict[str, str]): The HTTP headers for the request. + timeout (int): The timeout for the request in seconds. + + Returns: + Dict[str, Any]: The JSON response from the API. + """ + response: httpx.Response | None = None try: - - response = await self._client.post( + response = await self.async_raw_post( url, json=json, headers=headers, timeout=10 ) + handle_request_error(response) + return response.json() - - try: - response_data = response.json() - except ValueError: - response_data = {"errorMessage": response.text.strip() or ""} - - if not response.is_success: - error_message = response_data.get("errorMessage", "") - raise MpesaApiException( - MpesaError( - error_code=f"HTTP_{response.status_code}", - error_message=error_message, - status_code=response.status_code, - raw_response=response_data, - ) - ) - - return response_data - - except httpx.TimeoutException: - raise MpesaApiException( - MpesaError( - error_code="REQUEST_TIMEOUT", - error_message="Request to Mpesa timed out.", - status_code=None, - ) - ) - except httpx.ConnectError: - raise MpesaApiException( - MpesaError( - error_code="CONNECTION_ERROR", - error_message="Failed to connect to Mpesa API. Check network or URL.", - status_code=None, - ) - ) - except httpx.HTTPError as e: - + except (httpx.RequestError, ValueError) as e: raise MpesaApiException( MpesaError( error_code="REQUEST_FAILED", - error_message=f"HTTP request failed: {str(e)}", - status_code=None, - raw_response=None, + error_message=str(e), + status_code=getattr(response, "status_code", None), + raw_response=getattr(response, "text", None), ) - ) + ) from e + + @retry( + retry=retry_enabled(enabled=True), + wait=wait_random_exponential(multiplier=5, max=8), + stop=stop_after_attempt(3), + retry_error_callback=handle_retry_exception, + before_sleep=before_sleep_log(logger, logging.WARNING), + ) + async def async_raw_get( + self, + url: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + timeout: int = 10, + ) -> httpx.Response: + """Low-level GET request - may raise httpx exceptions.""" + if headers is None: + headers = {} + full_url = urljoin(self.base_url, url) + return await self._client.get( + full_url, params=params, headers=headers, timeout=timeout + ) async def get( self, @@ -100,60 +197,40 @@ async def get( params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: - """Sends an asynchronous GET request to the M-Pesa API.""" + """Sends a GET request to the M-Pesa API. + + Args: + url (str): The URL path for the request. + params (Optional[Dict[str, Any]]): The URL parameters. + headers (Optional[Dict[str, str]]): The HTTP headers. + timeout (int): The timeout for the request in seconds. + + Returns: + Dict[str, Any]: The JSON response from the API. + """ + response: httpx.Response | None = None try: - if headers is None: - headers = {} - - response = await self._client.get( - url, params=params, headers=headers, timeout=10 - ) - - try: - response_data = response.json() - except ValueError: - response_data = {"errorMessage": response.text.strip() or ""} - - if not response.is_success: - error_message = response_data.get("errorMessage", "") - raise MpesaApiException( - MpesaError( - error_code=f"HTTP_{response.status_code}", - error_message=error_message, - status_code=response.status_code, - raw_response=response_data, - ) - ) - - return response_data - - - except httpx.TimeoutException: - raise MpesaApiException( - MpesaError( - error_code="REQUEST_TIMEOUT", - error_message="Request to Mpesa timed out.", - status_code=None, - ) - ) - except httpx.ConnectError: - raise MpesaApiException( - MpesaError( - error_code="CONNECTION_ERROR", - error_message="Failed to connect to Mpesa API. Check network or URL.", - status_code=None, - ) - ) - except httpx.HTTPError as e: + response = await self.async_raw_get(url, params, headers, timeout = 10) + handle_request_error(response) + return response.json() + except (httpx.RequestError, ValueError) as e: raise MpesaApiException( MpesaError( error_code="REQUEST_FAILED", - error_message=f"HTTP request failed: {str(e)}", - status_code=None, - raw_response=None, + error_message=str(e), + status_code=getattr(response, "status_code", None), + raw_response=getattr(response, "text", None), ) - ) + ) from e async def aclose(self): """Manually close the underlying httpx client connection pool.""" await self._client.aclose() + + async def __aenter__(self) -> "MpesaAsyncHttpClient": + """Context manager entry point.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Context manager exit point. Closes the client.""" + await self._client.aclose() diff --git a/tests/unit/mpesa_express/test_stk_push.py b/tests/unit/mpesa_express/test_stk_push.py index 26248f8..ef26c6a 100644 --- a/tests/unit/mpesa_express/test_stk_push.py +++ b/tests/unit/mpesa_express/test_stk_push.py @@ -3,12 +3,13 @@ This module tests the StkPush class for initiating and querying M-Pesa STK Push transactions. """ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock , patch import pytest +import httpx from mpesakit.auth import AsyncTokenManager, TokenManager -from mpesakit.http_client import AsyncHttpClient, HttpClient +from mpesakit.http_client import AsyncHttpClient, MpesaHttpClient , MpesaAsyncHttpClient from mpesakit.mpesa_express.stk_push import ( AsyncStkPush, StkPush, @@ -27,10 +28,11 @@ def mock_token_manager(): return mock -@pytest.fixture -def mock_http_client(): - """Mock HttpClient for testing.""" - return MagicMock(spec=HttpClient) +@pytest.fixture(params=[True,False]) +def mock_http_client(request): + """Mock MpesaHttpClient for testing.""" + use_session=request.param + return MagicMock(spec=MpesaHttpClient(env="sandbox",use_session=use_session)) @pytest.fixture @@ -160,6 +162,26 @@ def test_stk_push_simulate_request_invalid_transaction_type(): StkPushSimulateRequest(**valid_kwargs) assert "TransactionType must be one of:" in str(excinfo.value) +@pytest.mark.parametrize("use_session", [True, False]) +def test_stk_push_retry(use_session): + """Test that mutliple simulation sync of stk_push is successful.""" + client = MpesaHttpClient(env="sandbox", use_session=use_session) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = {"MerchantRequestID": "12345", "ResponseCode": "0"} + + with patch.object(httpx.Client, "post", return_value=mock_response) as mock_post: + succcess_count= 0 + + for _ in range(100): + result = client.post("/test", json={}, headers={}) + if result["ResponseCode"] == "0": + succcess_count += 1 + + assert succcess_count == 100 + assert mock_post.call_count == 100 @pytest.fixture def mock_async_token_manager(): @@ -290,3 +312,28 @@ async def test_async_query_handles_http_error(async_stk_push, mock_async_http_cl with pytest.raises(Exception) as excinfo: await async_stk_push.query(request) assert "HTTP error" in str(excinfo.value) + +@pytest.mark.asyncio +async def test_stk_push_retry_async(): + """Test that multiple simulation of asnycClient stk_push is successful.""" + client = MpesaAsyncHttpClient() + + mock_response = MagicMock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = {"MerchantRequestID": "12345", "ResponseCode": "0"} + + async_mock = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient.post", async_mock) as mock_async_post: + + + success_count = 0 + + for _ in range(100): + result = await client.post("/test", json={}, headers={}) + if result["ResponseCode"] == "0": + success_count += 1 + + assert success_count == 100 + assert mock_async_post.await_count == 100 From 139847c78057467acac398a2c7bf90fa38eb96c5 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 20 Feb 2026 18:45:29 +0300 Subject: [PATCH 2/7] Addressed Issues raised by CI and CodeRabbit --- mpesakit/http_client/mpesa_async_http_client.py | 6 +++--- tests/unit/http_client/test_mpesa_async_http_client.py | 8 ++++---- tests/unit/mpesa_express/test_stk_push.py | 9 ++++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/mpesakit/http_client/mpesa_async_http_client.py b/mpesakit/http_client/mpesa_async_http_client.py index 5b641e5..54dcb2b 100644 --- a/mpesakit/http_client/mpesa_async_http_client.py +++ b/mpesakit/http_client/mpesa_async_http_client.py @@ -58,7 +58,7 @@ def handle_retry_exception(retry_state: RetryCallState): ) from exception elif isinstance(exception, httpx.ConnectError): raise MpesaApiException( - MpesaError(error_code="CONNECTION_ERROR", error_message=str(exception)) + MpesaError(error_code="CONNECTION_ERROR", error_message="Failed to connect to M-Pesa API.") ) from exception raise MpesaApiException( @@ -163,7 +163,7 @@ async def post( raise MpesaApiException( MpesaError( error_code="REQUEST_FAILED", - error_message=str(e), + error_message="HTTP request failed.", status_code=getattr(response, "status_code", None), raw_response=getattr(response, "text", None), ) @@ -217,7 +217,7 @@ async def get( raise MpesaApiException( MpesaError( error_code="REQUEST_FAILED", - error_message=str(e), + error_message="HTTP request failed.", status_code=getattr(response, "status_code", None), raw_response=getattr(response, "text", None), ) diff --git a/tests/unit/http_client/test_mpesa_async_http_client.py b/tests/unit/http_client/test_mpesa_async_http_client.py index 4170f80..65c3b23 100644 --- a/tests/unit/http_client/test_mpesa_async_http_client.py +++ b/tests/unit/http_client/test_mpesa_async_http_client.py @@ -48,7 +48,7 @@ async def test_post_success(async_client): assert result == {"foo": "bar"} mock_post.assert_called_once() - mock_post.assert_called_with("/test", json={"a": 1}, headers={"h": "v"}, timeout=10) + assert mock_post.call_args[0][0] == "https://sandbox.safaricom.co.ke/test" @pytest.mark.asyncio @@ -126,7 +126,7 @@ async def test_post_generic_httpx_error(async_client): await async_client.post("/error", json={}, headers={}) assert exc.value.error.error_code == "REQUEST_FAILED" - assert "protocol error" in exc.value.error.error_message + assert "HTTP request failed" in exc.value.error.error_message @pytest.mark.asyncio @@ -141,7 +141,7 @@ async def test_get_success(async_client): assert result == {"foo": "bar"} mock_get.assert_called_once() - mock_get.assert_called_with("/test", params={"a": 1}, headers={"h": "v"}, timeout=10) + assert mock_get.call_args[0][0] == "https://sandbox.safaricom.co.ke/test" @pytest.mark.asyncio @@ -171,7 +171,7 @@ async def test_get_timeout(async_client): await async_client.get("/timeout") assert exc.value.error.error_code == "REQUEST_TIMEOUT" - assert "timed out" in exc.value.error.error_message + assert "Test Timeout" in exc.value.error.error_message @pytest.mark.asyncio diff --git a/tests/unit/mpesa_express/test_stk_push.py b/tests/unit/mpesa_express/test_stk_push.py index ef26c6a..376e60f 100644 --- a/tests/unit/mpesa_express/test_stk_push.py +++ b/tests/unit/mpesa_express/test_stk_push.py @@ -31,8 +31,7 @@ def mock_token_manager(): @pytest.fixture(params=[True,False]) def mock_http_client(request): """Mock MpesaHttpClient for testing.""" - use_session=request.param - return MagicMock(spec=MpesaHttpClient(env="sandbox",use_session=use_session)) + return MagicMock(spec=MpesaHttpClient) @pytest.fixture @@ -163,7 +162,7 @@ def test_stk_push_simulate_request_invalid_transaction_type(): assert "TransactionType must be one of:" in str(excinfo.value) @pytest.mark.parametrize("use_session", [True, False]) -def test_stk_push_retry(use_session): +def test_stk_push_multiple_times(use_session): """Test that mutliple simulation sync of stk_push is successful.""" client = MpesaHttpClient(env="sandbox", use_session=use_session) @@ -314,8 +313,8 @@ async def test_async_query_handles_http_error(async_stk_push, mock_async_http_cl assert "HTTP error" in str(excinfo.value) @pytest.mark.asyncio -async def test_stk_push_retry_async(): - """Test that multiple simulation of asnycClient stk_push is successful.""" +async def test_async_stk_push_multiple_times(): + """Test that multiple simulation of asyncClient stk_push is successful.""" client = MpesaAsyncHttpClient() mock_response = MagicMock(spec=httpx.Response) From 33cea5429d3a41493ce288bf614dfee1428e7cfd Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 20 Feb 2026 18:52:35 +0300 Subject: [PATCH 3/7] bandit :B105: hardcoded_password_string --- mpesakit/mpesa_express/schemas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mpesakit/mpesa_express/schemas.py b/mpesakit/mpesa_express/schemas.py index 96f370b..8f4ccc3 100644 --- a/mpesakit/mpesa_express/schemas.py +++ b/mpesakit/mpesa_express/schemas.py @@ -89,7 +89,7 @@ class StkPushSimulateRequest(BaseModel): json_schema_extra={ "example": { "BusinessShortCode": 654321, - "Password": "bXlwYXNzd29yZA==", + "Password": "dGVzdF9wYXNzd29yZA==", "Timestamp": "20240607123045", "TransactionType": "CustomerPayBillOnline", "Amount": 10, @@ -499,7 +499,7 @@ class StkPushQueryRequest(BaseModel): json_schema_extra={ "example": { "BusinessShortCode": 654321, - "Password": "bXlwYXNzd29yZA==", + "Password": "dGVzdF9wYXNzd29yZA==", "Timestamp": "20240607123045", "CheckoutRequestID": "ws_CO_DMZ_123212312_2342347678234", } From b0dac3e5654a8c9343cdd7a44e229f0aa66f6ca3 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 20 Feb 2026 18:55:18 +0300 Subject: [PATCH 4/7] bandit :B105: hardcoded_password_string- used placeholder --- mpesakit/mpesa_express/schemas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mpesakit/mpesa_express/schemas.py b/mpesakit/mpesa_express/schemas.py index 8f4ccc3..a764e78 100644 --- a/mpesakit/mpesa_express/schemas.py +++ b/mpesakit/mpesa_express/schemas.py @@ -89,7 +89,7 @@ class StkPushSimulateRequest(BaseModel): json_schema_extra={ "example": { "BusinessShortCode": 654321, - "Password": "dGVzdF9wYXNzd29yZA==", + "Password": "", "Timestamp": "20240607123045", "TransactionType": "CustomerPayBillOnline", "Amount": 10, @@ -499,7 +499,7 @@ class StkPushQueryRequest(BaseModel): json_schema_extra={ "example": { "BusinessShortCode": 654321, - "Password": "dGVzdF9wYXNzd29yZA==", + "Password": "", "Timestamp": "20240607123045", "CheckoutRequestID": "ws_CO_DMZ_123212312_2342347678234", } From 0a4ef72ee6eb4ac3ff8eaa6368f858d1ba453f9a Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 20 Feb 2026 18:59:32 +0300 Subject: [PATCH 5/7] bandit :B105: hardcoded_password_string- used ignore placeholder --- mpesakit/mpesa_express/schemas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mpesakit/mpesa_express/schemas.py b/mpesakit/mpesa_express/schemas.py index a764e78..fd137aa 100644 --- a/mpesakit/mpesa_express/schemas.py +++ b/mpesakit/mpesa_express/schemas.py @@ -89,7 +89,7 @@ class StkPushSimulateRequest(BaseModel): json_schema_extra={ "example": { "BusinessShortCode": 654321, - "Password": "", + "Password": "bXlwYXNzd29yZA==", # nosec B105 "Timestamp": "20240607123045", "TransactionType": "CustomerPayBillOnline", "Amount": 10, @@ -499,7 +499,7 @@ class StkPushQueryRequest(BaseModel): json_schema_extra={ "example": { "BusinessShortCode": 654321, - "Password": "", + "Password": "bXlwYXNzd29yZA==", # nosec B105 "Timestamp": "20240607123045", "CheckoutRequestID": "ws_CO_DMZ_123212312_2342347678234", } From bbe169f6cd8e5b35687da964dcd25f9a46e4a04c Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Sat, 21 Feb 2026 12:06:55 +0300 Subject: [PATCH 6/7] Addressed coderabbit concerns --- .../http_client/mpesa_async_http_client.py | 51 +++++++++++-------- tests/unit/mpesa_express/test_stk_push.py | 34 +++++++------ 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/mpesakit/http_client/mpesa_async_http_client.py b/mpesakit/http_client/mpesa_async_http_client.py index 54dcb2b..79f1dd9 100644 --- a/mpesakit/http_client/mpesa_async_http_client.py +++ b/mpesakit/http_client/mpesa_async_http_client.py @@ -9,9 +9,9 @@ from urllib.parse import urljoin from tenacity import ( + AsyncRetrying, RetryCallState, before_sleep_log, - retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential, @@ -39,7 +39,7 @@ def handle_request_error(response: httpx.Response): error_code=f"HTTP_{response.status_code}", error_message=error_message, status_code=response.status_code, - raw_response=response_data, + raw_response=response.text, ) ) @@ -107,33 +107,43 @@ class MpesaAsyncHttpClient(AsyncHttpClient): base_url: str _client: httpx.AsyncClient - def __init__(self, env: str = "sandbox"): + def __init__(self, env: str = "sandbox", retry_enabled_flag: bool = True, wait_strategy=None, stop_strategy=None): """Initializes the MpesaAsyncHttpClient with the specified environment.""" self.base_url = self._resolve_base_url(env) self._client = httpx.AsyncClient(base_url=self.base_url) + self._retry_enabled_flag = retry_enabled_flag + self._wait_strategy = wait_strategy or wait_random_exponential(multiplier=5, max=8) + self._stop_strategy = stop_strategy or stop_after_attempt(3) + def _resolve_base_url(self, env: str) -> str: if env.lower() == "production": return "https://api.safaricom.co.ke" return "https://sandbox.safaricom.co.ke" + def _build_retrying(self): + return AsyncRetrying( + retry=retry_enabled(enabled=self._retry_enabled_flag), + wait=self._wait_strategy, + stop=self._stop_strategy, + retry_error_callback=handle_retry_exception, + before_sleep=before_sleep_log(logger, logging.WARNING), + reraise=True, + ) - @retry( - retry=retry_enabled(enabled=True), - wait=wait_random_exponential(multiplier=5, max=8), - stop=stop_after_attempt(3), - retry_error_callback=handle_retry_exception, - before_sleep=before_sleep_log(logger, logging.WARNING), - ) async def async_raw_post( self, url: str, json: Dict[str, Any], headers: Dict[str, str], timeout: int = 10 ) -> httpx.Response: """Low-level POST request - may raise httpx exceptions.""" full_url = urljoin(self.base_url, url) - return await self._client.post( - full_url, json=json, headers=headers, timeout=timeout) + + async for attempt in self._build_retrying(): + with attempt: + return await self._client.post( + full_url, json=json, headers=headers, timeout=timeout + ) @@ -169,13 +179,7 @@ async def post( ) ) from e - @retry( - retry=retry_enabled(enabled=True), - wait=wait_random_exponential(multiplier=5, max=8), - stop=stop_after_attempt(3), - retry_error_callback=handle_retry_exception, - before_sleep=before_sleep_log(logger, logging.WARNING), - ) + async def async_raw_get( self, url: str, @@ -187,9 +191,12 @@ async def async_raw_get( if headers is None: headers = {} full_url = urljoin(self.base_url, url) - return await self._client.get( - full_url, params=params, headers=headers, timeout=timeout - ) + + async for attempt in self._build_retrying(): + with attempt: + return await self._client.get( + full_url, params=params, headers=headers, timeout=timeout + ) async def get( self, diff --git a/tests/unit/mpesa_express/test_stk_push.py b/tests/unit/mpesa_express/test_stk_push.py index 376e60f..3ffff30 100644 --- a/tests/unit/mpesa_express/test_stk_push.py +++ b/tests/unit/mpesa_express/test_stk_push.py @@ -4,6 +4,7 @@ """ from unittest.mock import AsyncMock, MagicMock , patch +from tenacity import stop_after_attempt, wait_none import pytest @@ -28,8 +29,8 @@ def mock_token_manager(): return mock -@pytest.fixture(params=[True,False]) -def mock_http_client(request): +@pytest.fixture() +def mock_http_client(): """Mock MpesaHttpClient for testing.""" return MagicMock(spec=MpesaHttpClient) @@ -315,24 +316,27 @@ async def test_async_query_handles_http_error(async_stk_push, mock_async_http_cl @pytest.mark.asyncio async def test_async_stk_push_multiple_times(): """Test that multiple simulation of asyncClient stk_push is successful.""" - client = MpesaAsyncHttpClient() + async with MpesaAsyncHttpClient( + wait_strategy=wait_none(), + stop_strategy=stop_after_attempt(1), + ) as client: - mock_response = MagicMock(spec=httpx.Response) - mock_response.is_success = True - mock_response.status_code = 200 - mock_response.json.return_value = {"MerchantRequestID": "12345", "ResponseCode": "0"} + mock_response = MagicMock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = {"MerchantRequestID": "12345", "ResponseCode": "0"} - async_mock = AsyncMock(return_value=mock_response) + async_mock = AsyncMock(return_value=mock_response) - with patch("httpx.AsyncClient.post", async_mock) as mock_async_post: + with patch("httpx.AsyncClient.post", async_mock) as mock_async_post: - success_count = 0 + success_count = 0 - for _ in range(100): - result = await client.post("/test", json={}, headers={}) - if result["ResponseCode"] == "0": - success_count += 1 + for _ in range(100): + result = await client.post("/test", json={}, headers={}) + if result["ResponseCode"] == "0": + success_count += 1 - assert success_count == 100 + assert success_count == 100 assert mock_async_post.await_count == 100 From 7e3850640f3ce3de53c1161798e695b6e4552a68 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Sat, 21 Feb 2026 12:16:13 +0300 Subject: [PATCH 7/7] Resolve return in the dynamic loop --- .../http_client/mpesa_async_http_client.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/mpesakit/http_client/mpesa_async_http_client.py b/mpesakit/http_client/mpesa_async_http_client.py index 79f1dd9..30bccae 100644 --- a/mpesakit/http_client/mpesa_async_http_client.py +++ b/mpesakit/http_client/mpesa_async_http_client.py @@ -1,6 +1,7 @@ """MpesaAsyncHttpClient: An asynchronous client for making HTTP requests to the M-Pesa API.""" from typing import Dict, Any, Optional +from urllib import response import httpx import logging @@ -141,9 +142,15 @@ async def async_raw_post( async for attempt in self._build_retrying(): with attempt: - return await self._client.post( + response = await self._client.post( full_url, json=json, headers=headers, timeout=timeout ) + break + + if response is None: + raise RuntimeError("Retry loop exited without returning a response or raising an exception.") + + return response @@ -194,9 +201,16 @@ async def async_raw_get( async for attempt in self._build_retrying(): with attempt: - return await self._client.get( + response = await self._client.get( full_url, params=params, headers=headers, timeout=timeout ) + + if response is None: + raise RuntimeError("Retry loop exited without returning a response or raising an exception.") + + return response + + async def get( self,