Skip to content

Conversation

@RafaelJohn9
Copy link
Member

@RafaelJohn9 RafaelJohn9 commented Oct 14, 2025

Migrating development branch dev => develop

Summary by CodeRabbit

  • New Features

    • Optional persistent connections for faster, more efficient requests.
    • Automatic retry for transient request failures to improve reliability.
  • Refactor

    • Unified request handling with consistent JSON parsing and error translation.
    • Centralized error mapping to standardized exceptions with clear error codes.
  • Bug Fixes

    • More robust handling of timeouts, connection issues, and non-2xx responses.
  • Tests

    • Expanded test coverage for retry logic, error scenarios, and behavior across both persistent and non-persistent modes.

watersRand and others added 12 commits September 13, 2025 10:32
This commit introduces a robust HTTP client with optional session management. Users can now enable requests.Session to improve performance by reusing TCP connections for consecutive API calls.

Adds unit tests to cover both session-based and stateless client behaviors.

Refactors MpesaHttpClient to accept a use_session flag, with the default remaining as a stateless client.
@coderabbitai
Copy link

coderabbitai bot commented Oct 14, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

Adds retry logic with tenacity to HTTP GET/POST, centralizes error handling, and introduces optional persistent sessions in MpesaHttpClient. MpesaClient propagates a new use_session flag. Tests are refactored and expanded to cover session/non-session modes and retry behaviors. Tenacity is added as a dependency.

Changes

Cohort / File(s) Summary
HTTP client core
mpesakit/http_client/mpesa_http_client.py
Adds tenacity-based retries for GET/POST with a shared policy and retry error callback; introduces handle_request_error and handle_retry_exception; supports optional persistent requests.Session via new constructor flag use_session; updates request flow to use session or direct calls, apply timeouts, and map errors to MpesaApiException.
Client wrapper
mpesakit/mpesa_client.py
Extends MpesaClient.init with use_session and forwards it to MpesaHttpClient.
Tests (HTTP client)
tests/unit/http_client/test_mpesa_http_client.py
Parameterizes client for session/non-session modes; adds helper to patch correct request target; expands tests for retry success/failure, exception mapping, HTTP/JSON errors; refactors existing patches to dynamic target.
Dependencies
pyproject.toml
Adds dependency: tenacity>=8.2.3,<9.0.0.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Caller
  participant MpesaClient
  participant MpesaHttpClient
  participant Session as requests.Session
  participant Requests as requests

  Caller->>MpesaClient: post/get(...)
  MpesaClient->>MpesaHttpClient: post/get(path, payload, headers)

  alt use_session = True
    MpesaHttpClient->>Session: request (POST/GET, url, timeout)
  else use_session = False
    MpesaHttpClient->>Requests: request (POST/GET, url, timeout)
  end

  note over MpesaHttpClient: tenacity retry on timeout/connection errors

  alt Response non-2xx
    MpesaHttpClient->>MpesaHttpClient: handle_request_error(response)
    MpesaHttpClient-->>Caller: MpesaApiException
  else Response 2xx
    MpesaHttpClient-->>Caller: parsed JSON
  end

  opt Exhausted retries
    MpesaHttpClient->>MpesaHttpClient: handle_retry_exception(retry_state)
    MpesaHttpClient-->>Caller: MpesaApiException (REQUEST_TIMEOUT/CONNECTION_ERROR/REQUEST_FAILED)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I buffered my hops with retried grace,
Session burrows keep a steady pace.
If storms arise, I map the dread—
Codes and carrots, neatly thread.
With tenacious thumps I test each trail,
JSON shines where errors fail. 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The title “Migrate Dev => Develop” does not convey the actual content of this pull request, which adds retry logic, session support, and centralized error handling to MpesaHttpClient; it instead refers to a branch name migration unrelated to the implemented features. Please update the title to concisely summarize the primary changes in this PR, for example “Add retry logic and session support to MpesaHttpClient,” so it accurately reflects the content of the changes.
Description Check ⚠️ Warning The PR description only states “Migrating development branch dev => develop” and does not follow the required template, omitting all sections such as a detailed change description, the type of change, testing procedures, and checklist items needed for a proper review. Please fill out the description using the repository’s template by adding sections for a clear Description of the changes, Type of Change, How Has This Been Tested, and the Checklist to provide full context and verification details for reviewers.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.

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.

@RafaelJohn9
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Oct 14, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@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: 5

🧹 Nitpick comments (8)
pyproject.toml (1)

48-48: Consider allowing tenacity 9.x (latest fixes, same APIs used here).

If compatible in CI, prefer >=9.1.2,<10.0.0.

Apply this diff:

-  "tenacity>=8.2.3,<9.0.0" 
+  "tenacity>=9.1.2,<10.0.0"

Based on learnings

mpesakit/http_client/mpesa_http_client.py (2)

117-121: Make URL joining robust.

Concatenation can double/miss slashes. Use urllib.parse.urljoin.

Apply these diffs:

+from urllib.parse import urljoin
-        full_url = f"{self.base_url}{url}"
+        full_url = urljoin(self.base_url + "/", url)

And the same change in GET.

Also applies to: 154-159


23-44: Avoid double JSON parsing and broaden error message extraction.

Only parse JSON on error, and fall back across common keys.

Apply this diff:

-    try:
-        response_data = response.json()
-    except ValueError:
-        response_data = {"errorMessage": response.text.strip() or ""}
-
-    if not response.ok:
-        error_message = response_data.get("errorMessage", "")
+    if not response.ok:
+        try:
+            response_data = response.json()
+        except ValueError:
+            response_data = {}
+        error_message = (
+            response_data.get("errorMessage")
+            or response_data.get("message")
+            or response_data.get("error")
+            or (response.text.strip() if hasattr(response, "text") else "")
+        )
         raise MpesaApiException(
             MpesaError(
                 error_code=f"HTTP_{response.status_code}",
                 error_message=error_message,
                 status_code=response.status_code,
                 raw_response=response_data,
             )
         )
mpesakit/mpesa_client.py (2)

23-28: Document the new use_session parameter.

Add param doc so users discover session behavior and benefits.

Example:

def __init__(..., use_session: bool = False) -> None:
    """Initialize the MpesaClient with all service facades.

    Args:
        consumer_key: ...
        consumer_secret: ...
        environment: 'sandbox' or 'production'.
        use_session: Use a persistent requests.Session for connection pooling.
    """

27-27: Expose a close() to manage underlying HTTP session.

Forward http_client.close() so apps can cleanly release resources.

Add:

def close(self) -> None:
    if hasattr(self.http_client, "close"):
        self.http_client.close()

Optionally implement context manager to auto-close.

tests/unit/http_client/test_mpesa_http_client.py (3)

88-99: Align with “non-retryable” intent for RequestException and assert no retry.

Capture the mock to assert a single call.

Apply this diff:

-    with patch(patch_target,
-               side_effect=requests.RequestException("boom"),
-               ):
-        with pytest.raises(MpesaApiException) as exc:
-            client.post("/fail", json={}, headers={})
-            
-        assert exc.value.error.error_code == "REQUEST_FAILED"
+    with patch(patch_target) as mock_post:
+        mock_post.side_effect = requests.RequestException("boom")
+        with pytest.raises(MpesaApiException) as exc:
+            client.post("/fail", json={}, headers={})
+        assert exc.value.error.error_code == "REQUEST_FAILED"
+        mock_post.assert_called_once()

Note: This assumes retries exclude RequestException as suggested in the client.


193-205: Do the same for GET: RequestException should not be retried.

Assert only one attempt is made.

Apply this diff:

-    with patch(patch_target,
-               side_effect=requests.RequestException("boom"),
-               ):
-        with pytest.raises(MpesaApiException) as exc:
-            
-            client.get("/fail")
-            
-        assert exc.value.error.error_code == "REQUEST_FAILED"
+    with patch(patch_target) as mock_get:
+        mock_get.side_effect = requests.RequestException("boom")
+        with pytest.raises(MpesaApiException) as exc:
+            client.get("/fail")
+        assert exc.value.error.error_code == "REQUEST_FAILED"
+        mock_get.assert_called_once()

39-52: Optional: Add a test for JSON decode error on success (ok=True).

Covers the new JSON_DECODE_ERROR behavior on successful non-JSON responses.

Add:

def test_post_json_decode_error_on_success(client):
    patch_target = get_patch_target(client, "post")
    with patch(patch_target) as mock_post:
        mock_response = Mock()
        mock_response.ok = True
        mock_response.status_code = 200
        mock_response.json.side_effect = ValueError("invalid json")
        mock_response.text = "<html>not json</html>"
        mock_post.return_value = mock_response
        with pytest.raises(MpesaApiException) as exc:
            client.post("/ok-but-not-json", json={}, headers={})
        assert exc.value.error.error_code == "JSON_DECODE_ERROR"

def test_get_json_decode_error_on_success(client):
    patch_target = get_patch_target(client, "get")
    with patch(patch_target) as mock_get:
        mock_response = Mock()
        mock_response.ok = True
        mock_response.status_code = 200
        mock_response.json.side_effect = ValueError("invalid json")
        mock_response.text = "<html>not json</html>"
        mock_get.return_value = mock_response
        with pytest.raises(MpesaApiException) as exc:
            client.get("/ok-but-not-json")
        assert exc.value.error.error_code == "JSON_DECODE_ERROR"

Also applies to: 144-157

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1fbd53b and f0e98b8.

📒 Files selected for processing (4)
  • mpesakit/http_client/mpesa_http_client.py (2 hunks)
  • mpesakit/mpesa_client.py (1 hunks)
  • pyproject.toml (1 hunks)
  • tests/unit/http_client/test_mpesa_http_client.py (8 hunks)

Comment on lines +77 to 90
_session: Optional[requests.Session] = None

def __init__(self, env: str = "sandbox"):
"""Initializes the MpesaHttpClient with the specified environment.
def __init__(self, env: str = "sandbox", use_session: bool = False):
"""Initializes the MpesaHttpClient instance.
Args:
env (str): The environment to use, either 'sandbox' or 'production'.
Defaults to 'sandbox'.
env (str): The environment to connect to ('sandbox' or 'production').
use_session (bool): Whether to use a persistent session.
"""
self.base_url = self._resolve_base_url(env)
if use_session:
self._session = requests.Session()
self._session.trust_env = False

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Close the Session to avoid resource leaks; support context management.

Provide an explicit close() and context manager to manage the persistent session.

Add these methods:

def close(self) -> None:
    if self._session:
        self._session.close()

def __enter__(self):
    return self

def __exit__(self, exc_type, exc, tb):
    self.close()

Consider having MpesaClient expose/forward close() as well.

🤖 Prompt for AI Agents
In mpesakit/http_client/mpesa_http_client.py around lines 77 to 90, the
persistent requests.Session created when use_session=True is never closed and
the class lacks context-manager support; add a public close(self) method that
calls self._session.close() if present, and implement __enter__(self) returning
self and __exit__(self, exc_type, exc, tb) that calls close() to ensure sessions
are released; also update any MpesaClient wrapper to forward/Expose a close()
method so callers can explicitly close the client when not using a
with-statement.

Comment on lines +96 to 104
@retry(
retry=retry_if_exception_type(requests.exceptions.Timeout) |
retry_if_exception_type(requests.exceptions.ConnectionError)|
retry_if_exception_type(requests.exceptions.RequestException),
wait=wait_fixed(2),
stop=stop_after_attempt(3),
retry_error_callback=handle_retry_exception
)
def post(
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Overly broad retries; tighten to transient network errors and improve backoff/logging.

Retrying on RequestException will retry non-transient issues (e.g., InvalidURL). Restrict to Timeout and ConnectionError. Use exponential backoff with jitter and log before sleep.

Apply these diffs:

-from tenacity import (
-    retry,
-    stop_after_attempt,
-    wait_fixed,
-    retry_if_exception_type,
-    RetryCallState
-)
+from tenacity import (
+    retry,
+    stop_after_attempt,
+    wait_random_exponential,
+    retry_if_exception_type,
+    RetryCallState,
+    before_sleep_log,
+)
 @retry(
-        retry=retry_if_exception_type(requests.exceptions.Timeout) |
-              retry_if_exception_type(requests.exceptions.ConnectionError)|
-              retry_if_exception_type(requests.exceptions.RequestException),
-        wait=wait_fixed(2),
+        retry=retry_if_exception_type(requests.exceptions.Timeout)
+              | retry_if_exception_type(requests.exceptions.ConnectionError),
+        wait=wait_random_exponential(multiplier=0.5, max=8),
         stop=stop_after_attempt(3),
-        retry_error_callback=handle_retry_exception
+        retry_error_callback=handle_retry_exception,
+        before_sleep=before_sleep_log(logger, logging.WARNING),
     )

Repeat the same change for the GET decorator below.

Also applies to: 127-135

🤖 Prompt for AI Agents
In mpesakit/http_client/mpesa_http_client.py around lines 96-104 (and repeat the
same change at 127-135), the retry decorator is too broad (includes
requests.exceptions.RequestException); restrict retries to only
requests.exceptions.Timeout and requests.exceptions.ConnectionError, replace the
fixed wait with an exponential backoff plus jitter (e.g., use
wait_exponential_jitter or compose wait_exponential with jitter) and add a
logging call before each retry/sleep to record the exception and attempt number;
update both the POST and GET decorator blocks accordingly and ensure
retry_error_callback remains wired.

Comment on lines +123 to 126
handle_request_error(response)

return response.json()

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle JSON decode errors on successful responses.

response.json() can raise ValueError (e.g., 204 or text/plain). Convert to MpesaApiException.

Apply this diff to both POST and GET return paths:

-        return response.json()
+        try:
+            return response.json()
+        except ValueError as e:
+            raise MpesaApiException(
+                MpesaError(
+                    error_code="JSON_DECODE_ERROR",
+                    error_message=str(e),
+                    status_code=getattr(response, "status_code", None),
+                    raw_response=getattr(response, "text", None),
+                )
+            ) from e

Also applies to: 160-162

🤖 Prompt for AI Agents
In mpesakit/http_client/mpesa_http_client.py around lines 123-126 (and similarly
160-162), the code calls response.json() directly which can raise ValueError for
non-JSON successful responses (e.g., 204 or text/plain); wrap the
response.json() call in a try/except ValueError and convert that into an
MpesaApiException that includes context (status code and response text or a
short message) so callers get a consistent error type; apply this change to both
the POST and GET return paths.

dependencies = [
"pydantic >=2.10.6,<3.0.0",
"pydantic[email] >=2.10.6,<3.0.0",
"requests >=2.32.3,<3.0.0",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Bump requests lower bound to 2.32.5 (security fix).

2.32.3 allows a vulnerable version. Use >=2.32.5 for CVE-2024-47081 fix.

Apply this diff:

-  "requests >=2.32.3,<3.0.0",
+  "requests >=2.32.5,<3.0.0",

Based on learnings

🤖 Prompt for AI Agents
In pyproject.toml at line 45, the requests dependency lower bound is pinned to
2.32.3 which is vulnerable; update the requirement to "requests >=2.32.5,<3.0.0"
to include the CVE-2024-47081 fix, then regenerate/update the lockfile (poetry
lock / pip-tools / your dependency manager) and run tests to ensure no
regressions.

RafaelJohn9 and others added 2 commits October 14, 2025 11:34
Don’t call logging.basicConfig in a library module.

This globally alters app logging. Remove basicConfig; keep module logger only.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
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.

3 participants