Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 11 additions & 27 deletions mpesakit/http_client/mpesa_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

from typing import Dict, Any, Optional
import requests
import httpx

from mpesakit.errors import MpesaError, MpesaApiException
from .http_client import HttpClient
Expand Down Expand Up @@ -54,14 +54,15 @@ def post(
"""
try:
full_url = f"{self.base_url}{url}"
response = requests.post(full_url, json=json, headers=headers, timeout=10)
with httpx.Client(timeout=10) as client:
response = client.post(full_url, json=json, headers=headers)

try:
response_data = response.json()
except ValueError:
response_data = {"errorMessage": response.text.strip() or ""}

if not response.ok:
if response.status_code >= 400:
error_message = response_data.get("errorMessage", "")
raise MpesaApiException(
MpesaError(
Expand All @@ -74,23 +75,15 @@ def post(

return response_data

except requests.Timeout:
except httpx.TimeoutException:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_TIMEOUT",
error_message="Request to Mpesa timed out.",
status_code=None,
)
)
except requests.ConnectionError:
raise MpesaApiException(
MpesaError(
error_code="CONNECTION_ERROR",
error_message="Failed to connect to Mpesa API. Check network or URL.",
status_code=None,
)
)
except requests.RequestException as e:
except httpx.RequestError as e:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_FAILED",
Expand Down Expand Up @@ -124,16 +117,15 @@ def get(
headers = {}
full_url = f"{self.base_url}{url}"

response = requests.get(
full_url, params=params, headers=headers, timeout=10
) # Add timeout
with httpx.Client(timeout=10) as client:
response = client.get(full_url, params=params, headers=headers)

try:
response_data = response.json()
except ValueError:
response_data = {"errorMessage": response.text.strip() or ""}

if not response.ok:
if not response.is_success:
error_message = response_data.get("errorMessage", "")
raise MpesaApiException(
MpesaError(
Expand All @@ -146,23 +138,15 @@ def get(

return response_data

except requests.Timeout:
except httpx.TimeoutException:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_TIMEOUT",
error_message="Request to Mpesa timed out.",
status_code=None,
)
)
except requests.ConnectionError:
raise MpesaApiException(
MpesaError(
error_code="CONNECTION_ERROR",
error_message="Failed to connect to Mpesa API. Check network or URL.",
status_code=None,
)
)
except requests.RequestException as e:
except httpx.RequestError as e:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_FAILED",
Expand Down
30 changes: 16 additions & 14 deletions tests/integration/mpesa_express/test_stk_push_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import os
import time
import pytest
import requests
import httpx
from threading import Thread
from dotenv import load_dotenv

Expand Down Expand Up @@ -82,7 +82,8 @@ def test_stk_push_full_e2e_with_query(stk_service, fastapi_server, ngrok_tunnel)
print("🔗 Starting E2E Test: STK Push, Callback, and Query")
# 1. Clear previous callbacks
callback_base_url = f"{ngrok_tunnel}/mpesa/callback"
requests.post(f"{callback_base_url}/clear")
with httpx.Client() as client:
client.post(f"{callback_base_url}/clear")

callback_url = f"{ngrok_tunnel}/mpesa/callback"
print(f"📨 Using callback URL: {callback_url}")
Expand Down Expand Up @@ -111,18 +112,19 @@ def test_stk_push_full_e2e_with_query(stk_service, fastapi_server, ngrok_tunnel)
print("⏳ Waiting up to 30 seconds for callback...")
callback_received = False
callback = None
for _ in range(30):
time.sleep(1)
r = requests.get(f"{callback_base_url}/latest", timeout=45)
if r.status_code == 200:
callback_received = True
callback_json = r.json()["parsed"]
callback = StkPushSimulateCallback.model_validate(callback_json)
body = callback.Body.stkCallback
print(
f"🎉 Callback received: ResultCode={body.ResultCode}, Desc={body.ResultDesc}"
)
break
with httpx.Client() as client:
for _ in range(30):
time.sleep(1)
r = client.get(f"{callback_base_url}/latest", timeout=45)
if r.status_code == 200:
callback_received = True
callback_json = r.json()["parsed"]
callback = StkPushSimulateCallback.model_validate(callback_json)
body = callback.Body.stkCallback
print(
f"🎉 Callback received: ResultCode={body.ResultCode}, Desc={body.ResultDesc}"
)
break

if not callback_received:
print(
Expand Down
44 changes: 20 additions & 24 deletions tests/unit/http_client/test_mpesa_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
HTTP POST and GET request handling, and error handling for various scenarios.
"""

import requests
import httpx
import pytest
from unittest.mock import Mock, patch
from mpesakit.http_client.mpesa_http_client import MpesaHttpClient
Expand Down Expand Up @@ -32,9 +32,9 @@ def test_base_url_production():

def test_post_success(client):
"""Test successful POST request returns expected JSON."""
with patch("mpesakit.http_client.mpesa_http_client.requests.post") as mock_post:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.post") as mock_post:
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"foo": "bar"}
mock_post.return_value = mock_response

Expand All @@ -45,9 +45,8 @@ def test_post_success(client):

def test_post_http_error(client):
"""Test POST request returns MpesaApiException on HTTP error."""
with patch("mpesakit.http_client.mpesa_http_client.requests.post") as mock_post:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.post") as mock_post:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.json.return_value = {"errorMessage": "Bad Request"}
mock_post.return_value = mock_response
Expand All @@ -60,9 +59,8 @@ def test_post_http_error(client):

def test_post_json_decode_error(client):
"""Test POST request handles JSON decode error gracefully."""
with patch("mpesakit.http_client.mpesa_http_client.requests.post") as mock_post:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.post") as mock_post:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 500
mock_response.json.side_effect = ValueError()
mock_response.text = "Internal Server Error"
Expand All @@ -77,8 +75,8 @@ def test_post_json_decode_error(client):
def test_post_request_exception(client):
"""Test POST request raises MpesaApiException on generic exception."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.post",
side_effect=requests.RequestException("boom"),
"mpesakit.http_client.mpesa_http_client.httpx.Client.post",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(MpesaApiException) as exc:
client.post("/fail", json={}, headers={})
Expand All @@ -88,8 +86,8 @@ def test_post_request_exception(client):
def test_post_timeout(client):
"""Test POST request raises MpesaApiException on timeout."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.post",
side_effect=requests.Timeout,
"mpesakit.http_client.mpesa_http_client.httpx.Client.post",
side_effect=httpx.TimeoutException,
):
with pytest.raises(MpesaApiException) as exc:
client.post("/timeout", json={}, headers={})
Expand All @@ -99,8 +97,8 @@ def test_post_timeout(client):
def test_post_connection_error(client):
"""Test POST request raises MpesaApiException on connection error."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.post",
side_effect=requests.ConnectionError,
"mpesakit.http_client.mpesa_http_client.httpx.Client.post",
side_effect=httpx.ConnectError,
):
with pytest.raises(MpesaApiException) as exc:
client.post("/conn", json={}, headers={})
Expand All @@ -109,7 +107,7 @@ def test_post_connection_error(client):

def test_get_success(client):
"""Test successful GET request returns expected JSON."""
with patch("mpesakit.http_client.mpesa_http_client.requests.get") as mock_get:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.get") as mock_get:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"foo": "bar"}
Expand All @@ -122,9 +120,8 @@ def test_get_success(client):

def test_get_http_error(client):
"""Test GET request returns MpesaApiException on HTTP error."""
with patch("mpesakit.http_client.mpesa_http_client.requests.get") as mock_get:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.get") as mock_get:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 404
mock_response.json.return_value = {"errorMessage": "Not Found"}
mock_get.return_value = mock_response
Expand All @@ -137,9 +134,8 @@ def test_get_http_error(client):

def test_get_json_decode_error(client):
"""Test GET request handles JSON decode error gracefully."""
with patch("mpesakit.http_client.mpesa_http_client.requests.get") as mock_get:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.get") as mock_get:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 500
mock_response.json.side_effect = ValueError()
mock_response.text = "Internal Server Error"
Expand All @@ -154,8 +150,8 @@ def test_get_json_decode_error(client):
def test_get_request_exception(client):
"""Test GET request raises MpesaApiException on generic exception."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.get",
side_effect=requests.RequestException("boom"),
"mpesakit.http_client.mpesa_http_client.httpx.Client.get",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(MpesaApiException) as exc:
client.get("/fail")
Expand All @@ -165,8 +161,8 @@ def test_get_request_exception(client):
def test_get_timeout(client):
"""Test GET request raises MpesaApiException on timeout."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.get",
side_effect=requests.Timeout,
"mpesakit.http_client.mpesa_http_client.httpx.Client.get",
side_effect=httpx.TimeoutException,
):
with pytest.raises(MpesaApiException) as exc:
client.get("/timeout")
Expand All @@ -176,8 +172,8 @@ def test_get_timeout(client):
def test_get_connection_error(client):
"""Test GET request raises MpesaApiException on connection error."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.get",
side_effect=requests.ConnectionError,
"mpesakit.http_client.mpesa_http_client.httpx.Client.get",
side_effect=httpx.ConnectError,
):
with pytest.raises(MpesaApiException) as exc:
client.get("/conn")
Expand Down
Loading