Skip to content

Conversation

@RafaelJohn9
Copy link
Member

@RafaelJohn9 RafaelJohn9 commented Jan 17, 2026

Description

This PR migrates the MpesaHttpClient from the requests library to httpx. The primary motivation is to modernize the HTTP layer, gain better timeout handling, prepare for future async support, and align with current Python ecosystem trends. All public APIs remain unchanged—this is a drop-in internal upgrade.

The migration includes:

  • Replacing all requests usages with equivalent httpx calls
  • Updating exception handling to use httpx.RequestError, TimeoutException, and ConnectError
  • Switching session management to httpx.Client
  • Adapting response checks from response.ok to response.is_success
  • Updating both unit and integration tests to correctly mock and validate against httpx

Fixes #54

Type of Change

  • New feature (non-breaking change that adds functionality)
  • Refactor (code structure improvements, no new functionality)
  • Tests (addition or improvement of tests)

How Has This Been Tested?

  • Unit tests: All HTTP client unit tests have been updated to use httpx.Response and properly mock httpx.Client.post/get. Retries, error wrapping, success paths, and JSON parsing failures are all verified.
  • Integration tests: The end-to-end STK Push test now uses httpx.Client for callback validation and remains fully functional in sandbox mode.
  • Manual validation: Verified successful STK push simulation against M-Pesa sandbox using the updated client.
  • Backward compatibility: Confirmed that existing service-layer code requires no changes—the public post() and get() signatures and behaviors are preserved.

Checklist

  • My code follows the project's coding style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (if applicable)
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Additional Context

While this change is currently synchronous, it lays the groundwork for optional async support in the future by using httpx, which supports both sync and async clients with nearly identical APIs. Additionally, httpx provides more precise timeout controls and native HTTP/2 support, which may improve performance in high-throughput scenarios.

The loosening of raw_response from Dict[str, Any] to Any in MpesaError ensures robustness when dealing with malformed or non-JSON error responses from upstream systems.

Summary by CodeRabbit

  • Tests

    • End-to-end and unit tests strengthened with explicit client lifecycle, global timeouts, retries, rate-limit handling, extended status polling, and more robust validation.
  • Chores

    • Under‑the‑hood HTTP client implementation replaced for more reliable networking and error propagation.
    • Public client initialization gained an option to control persistent client usage.
    • Error payload handling relaxed to accept broader response formats.

✏️ Tip: You can customize this high-level summary in your review settings.

- Replace `requests` with `httpx` throughout the MpesaHttpClient
- Update retry logic to handle `httpx` exceptions (TimeoutException, ConnectError)
- Refactor session management to use `httpx.Client`
- Adjust error handling to use `response.is_success` instead of `response.ok`
- Update unit tests to mock `httpx.Client` methods and use `httpx.Response`
- Fix integration test to use `httpx.Client` for callback validation
- Loosen `raw_response` type in `MpesaError` to support non-dict payloads

This change enables async support readiness, better timeout control, and modern HTTP/2 capabilities while maintaining the same public API.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 17, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Migrates internal HTTP usage from requests to httpx, broadens MpesaError.raw_response type to Optional[Any], renames the internal session to _client, and updates unit and integration tests to use httpx with adjusted error, retry, and timeout handling.

Changes

Cohort / File(s) Summary
Type Annotation Updates
mpesakit/errors.py
Changed MpesaError.raw_response from Optional[Dict[str, Any]] to Optional[Any] and removed the unused Dict import.
HTTP Client Migration
mpesakit/http_client/mpesa_http_client.py
Replaced requests with httpx: renamed internal _session_client (httpx.Client), updated _raw_post/_raw_getto use/returnhttpx.Response, swapped exception handling to httpx.*` types, adjusted retry/error detection, timeout propagation, and client lifecycle/closing.
Integration Tests
tests/integration/mpesa_express/test_stk_push_e2e.py
Replaced requests calls with an explicit httpx.Client lifecycle and timeouts; replaced direct GET/POST with httpx client calls, added robust polling and rate-limit handling, and imported httpx, TokenManager, MpesaHttpClient, and atexit.
Unit Tests
tests/unit/http_client/test_mpesa_http_client.py
Updated mocks and assertions from requests to httpx; patched _raw_post/_raw_get and httpx.Client.post/get for retry tests; adapted exception scenarios to httpx exceptions; tests updated for new use_session constructor parameter and _client attribute.
Minor Tests Formatting
tests/unit/c2b/test_c2b.py
Whitespace formatting change between tests only.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Test as "Test Script"
participant Token as "TokenManager"
participant Client as "MpesaHttpClient\n(httpx.Client)"
participant MPESA as "MPESA API"
participant Callback as "Callback Service"

rect rgba(200,200,255,0.5)
Test->>Token: request token
Token-->>Test: return token
end

rect rgba(200,255,200,0.5)
Test->>Client: send STK Push (uses token)
Client->>MPESA: POST /stkpush
MPESA-->>Client: 200 OK (initiated)
Client-->>Test: deliver response
end

rect rgba(255,230,200,0.5)
MPESA->>Callback: send callback (async)
Callback-->>Test: callback received (push data)
end

rect rgba(240,240,200,0.5)
alt no callback within timeout
    Test->>Client: poll status endpoint (GET)
    Client->>MPESA: GET /transactionstatus
    MPESA-->>Client: status response
    Client-->>Test: return status
end

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #118: Very similar migration from requests to httpx including MpesaHttpClient changes and MpesaError typing adjustments.
  • PR #117: Overlaps on MpesaHttpClient refactors (session/client lifecycle and low-level request handling).
  • PR #89: Modifies request handling and retry logic in mpesakit/http_client/mpesa_http_client.py; closely related to this migration.

Poem

🐰 I hopped from requests' den to httpx's light,

New client paws, timeouts set just right,
Retries twitched their whiskers in tune,
Callbacks danced beneath the moon,
A rabbit's cheer for code that took flight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the primary change: migrating the HTTP client from requests to httpx.
Description check ✅ Passed The PR description covers all key template sections: clear description of changes, type of change (marked), testing approach, and completed checklist items.
Linked Issues check ✅ Passed All three requirements from issue #54 are fully met: requests replaced with httpx in http_client, unit tests updated to use httpx, and integration tests updated to use httpx.
Out of Scope Changes check ✅ Passed All changes align with the httpx migration objective. The MpesaError.raw_response type broadening is justified as supporting robustness for non-JSON responses, which is a necessary adaptation for the migration.
Docstring Coverage ✅ Passed Docstring coverage is 89.66% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@tests/unit/http_client/test_mpesa_http_client.py`:
- Around line 114-121: The assertions are placed inside the with
pytest.raises(MpesaApiException) block so they never run; move the mock and
exception assertions outside the context manager: keep the patch of
client._raw_post with side_effect httpx.ConnectError and call
client.post("/test", json={"a": 1}, headers={"h": "v"}) inside with
pytest.raises(MpesaApiException) as exc, then after that block assert
mock_raw_post.call_count == 3 and assert exc.value.error.error_code ==
"CONNECTION_ERROR" to verify retry attempts and the raised MpesaApiException.
- Around line 148-159: The assertions in test_get_json_decode_error are placed
inside the with pytest.raises(MpesaApiException) context so they never run;
update the test_get_json_decode_error to keep the mock setup (patching
client._raw_get and setting mock_response.json to raise ValueError) and call
client.get("/fail") inside the with pytest.raises(MpesaApiException) as exc
block, but move the assertions (checking exc.value.error.error_code ==
"HTTP_500" and that "Internal Server Error" is in exc.value.error.error_message)
to immediately after the with block so they execute, ensuring the test still
validates client.get error handling.
- Around line 75-79: The assertions for the raised MpesaApiException are
currently inside the with pytest.raises(MpesaApiException) context and never
run; move the two asserts so they execute after the context manager exits.
Specifically, keep the call that should raise (client.post("/fail", json={},
headers={})) inside the with pytest.raises(MpesaApiException) as exc: block,
then immediately after that block assert on exc.value.error.error_code ==
"HTTP_500" and that "Internal Server Error" is in exc.value.error.error_message
to validate the exception details.
- Around line 186-195: The test test_get_fails_after_max_retries has assertions
placed inside the with pytest.raises(MpesaApiException) context so they never
run; move the assertions that check mock_raw_get.call_count and
exc.value.error.error_code to after the with block and capture the raised
exception via the context manager (the exc variable) so you can assert on its
contents—update references in the test to ensure mock_raw_get is patched on
client._raw_get and that the side_effect remains httpx.TimeoutException("Read
timed out.") while asserting call_count == 3 and exc.value.error.error_code ==
"REQUEST_TIMEOUT" outside the with block.
- Around line 53-63: The mock for client._raw_post is exiting before the call
under test, causing the real method to be invoked; in test_post_http_error
ensure the patch.object context remains active when calling client.post by
moving the client.post(...) invocation (and its pytest.raises block) inside the
with patch.object(client, "_raw_post") as mock_raw_post: block (or convert to a
decorator-based patch) so mock_raw_post.return_value is used; keep the
assertions on exc.value.error.* after the call within the same context and
reference test_post_http_error, client._raw_post, client.post, and
MpesaApiException when applying the change.
- Around line 44-50: The tests create httpx.Response objects using a nonexistent
json= kwarg which raises TypeError; update each Response(...) construction
(e.g., the mock_response used with patch.object(client, "_raw_post") / variable
mock_raw_post) to pass content=json.dumps({...}).encode() and appropriate
status_code (and headers if needed) or alternatively use pytest-httpx test
helpers; ensure you import json where used and fix all other occurrences in this
file (the other Response(...) instances around the same tests) to use content
rather than json.
🧹 Nitpick comments (1)
mpesakit/http_client/mpesa_http_client.py (1)

184-200: GET method has hardcoded timeout while POST accepts a configurable timeout.

_raw_get uses a hardcoded timeout=10 (lines 196, 200), but _raw_post accepts a timeout parameter. Consider adding a timeout parameter to _raw_get and get for consistency.

♻️ Proposed refactor
     def _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)
         if self._client:
             return self._client.get(
-                full_url, params=params, headers=headers, timeout=10
+                full_url, params=params, headers=headers, timeout=timeout
             )
         else:
             with httpx.Client() as client:
-                return client.get(full_url, params=params, headers=headers, timeout=10)
+                return client.get(full_url, params=params, headers=headers, timeout=timeout)

And similarly update the public get method signature.

Update unit tests for retry exhaustion scenarios to mock httpx.Client methods
instead of _raw_* methods, ensuring the retry decorator is properly exercised.
Also fix indentation and assertion alignment in error-handling tests.
Expose `timeout` argument in both public `get()` and internal `_raw_get()`
methods to match the POST interface, enabling consistent timeout control
across all HTTP operations.
@RafaelJohn9 RafaelJohn9 merged commit e9f0856 into master Jan 17, 2026
7 checks passed
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
mpesakit/http_client/mpesa_http_client.py (2)

212-218: Docstring missing timeout parameter (lint failure).

CI reports D417 for the get docstring. Add the missing timeout description.

✏️ Docstring fix
         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.

105-119: trust_env is ignored when use_session=False.

The non-session path creates ephemeral httpx.Client() instances without passing trust_env, so the flag is ineffective and will silently use httpx defaults regardless of user intent. Store trust_env as an instance variable and reuse it consistently across all Client instantiations.

🔧 Proposed fix
 class MpesaHttpClient(HttpClient):
     """A client for making HTTP requests to the M-Pesa API."""
 
     base_url: str
     _client: Optional[httpx.Client] = None
+    _trust_env: bool
 
     def __init__(
         self, env: str = "sandbox", use_session: bool = False, trust_env: bool = True
     ):
@@
         """
         self.base_url = self._resolve_base_url(env)
+        self._trust_env = trust_env
         if use_session:
-            self._client = httpx.Client(trust_env=trust_env)
+            self._client = httpx.Client(trust_env=self._trust_env)
@@
         if self._client:
             return self._client.post(
                 full_url, json=json, headers=headers, timeout=timeout
             )
         else:
-            with httpx.Client() as client:
+            with httpx.Client(trust_env=self._trust_env) as client:
                 return client.post(
                     full_url, json=json, headers=headers, timeout=timeout
                 )
@@
         if self._client:
             return self._client.get(
                 full_url, params=params, headers=headers, timeout=timeout
             )
         else:
-            with httpx.Client() as client:
+            with httpx.Client(trust_env=self._trust_env) as client:
                 return client.get(
                     full_url, params=params, headers=headers, timeout=timeout
                 )
🧹 Nitpick comments (1)
mpesakit/http_client/mpesa_http_client.py (1)

162-162: Verify Python version for Response | None.

This syntax requires Python ≥3.10. If you still support 3.9 or lower, switch to Optional[...] (or bump the minimum version).

🔧 Compatible typing alternative
-        response: httpx.Response | None = None
+        response: Optional[httpx.Response] = None
@@
-        response: httpx.Response | None = None
+        response: Optional[httpx.Response] = None

Also applies to: 222-222

@RafaelJohn9 RafaelJohn9 deleted the refactor/requests-to-httpx branch January 17, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Migrate from requests to httpx

2 participants