Skip to content

feat: add retry logic for 5xx gateway errors in async HTTP client - #397

Merged
luislhl merged 7 commits into
mainfrom
feat/retry-on-5xx-gateway-errors
Jul 10, 2026
Merged

feat: add retry logic for 5xx gateway errors in async HTTP client#397
luislhl merged 7 commits into
mainfrom
feat/retry-on-5xx-gateway-errors

Conversation

@luislhl

@luislhl luislhl commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Problem

When a fullnode is temporarily unreachable (e.g. nginx returns 502 Bad Gateway), the daemon was generating three log entries per node per 1-second polling cycle, triggering spurious alerts:

1. WARNING  hathor_core_error      {client: async, path: /v1a/status, status: 502, body: <html>...}
2. ERROR    hathor_core_error      {client: async, path: /v1a/status, error: ContentTypeError(...)}
3. WARNING  collect_status_error   {error: ContentTypeError(...)}

Root cause

HathorCoreAsyncClient.get() had no retry mechanism, and response.json() was called unconditionally:

  1. Any non-2xx status code immediately logged a WARNING with the raw body.
  2. response.json() was then called on a 502 response whose body is nginx HTML — this raised a ContentTypeError, caught by the except branch, which logged an ERROR and returned {"error": repr(exc)} (the original body lost).
  3. _send() in CollectNodesStatuses saw the "error" key in the returned dict and logged a third WARNING.

Solution

1. Retry transient gateway errors

HathorCoreAsyncClient.get() now retries retryable gateway errors (502, 503, 504):

  • MAX_RETRIES = 2 — up to 2 retries before giving up
  • RETRY_DELAY = 1.0s — wait 1 second between attempts
  • RETRYABLE_STATUS_CODES = {502, 503, 504} — only gateway-level transient errors are retried

All attempts share a single aiohttp.ClientSession. On a retryable status the body is read (consuming the connection cleanly) and the loop either sleeps + retries or, once retries are exhausted, logs a single WARNING and returns the decoded body (see below). Non-retryable 4xx errors are never retried. The get() docstring now also clarifies that timeout is per-attempt, so total elapsed time may exceed it when retries happen.

2. Decode error bodies instead of unconditionally calling response.json()

For any error response (status > 299), _decode_error_body returns:

  • the server-provided JSON body as-is when the body is valid JSON (callers keep the full error details — this matches main's behavior for JSON error bodies);
  • {"error": "status X", "body": <raw body>} when the body is not JSON (e.g. GCP load balancer / nginx 5xx that come back as HTML/plain text).

This is the key fix versus main: a non-JSON error body no longer reaches response.json(), so it no longer raises a ContentTypeError (eliminating the spurious ERROR log) and the raw error text is preserved instead of being replaced by repr(exc). Successful (<= 299) responses are unchanged: the JSON body is returned verbatim.

3. Retry opt-out for the health endpoint

Because a 503 from /v1a/health is a legitimate answer (unhealthy fullnode) rather than a transient gateway failure, retrying it would only burn the tight healthcheck timeout budget (5s client / 6s lambda). get() takes a retry flag (default True) and HealthcheckGateway passes retry=False, so genuine gateway errors are still retried everywhere else.

Outcome (per failing poll cycle, vs main)

Scenario main This PR
Transient 502 (recovers within retries) 1 WARNING + 1 ERROR + 1 WARNING = 3 0
Persistent 502 (all retries fail) 1 WARNING + 1 ERROR + 1 WARNING = 3 2hathor_core_error WARNING + collect_status_error WARNING; the spurious ERROR is gone

Why this fits the two current callers

This client is consumed in only two places, and neither relies on the HTTP status code to detect errors — which matches hathor-core's convention of frequently returning errors with HTTP 200 and signalling them in the body (many resources return {'success': False, ...} without ever setting a response code). Error detection here is intentionally body-based, not status-based.

Caller Endpoint hathor-core behavior How the caller reads the result
CollectNodesStatuses (usecases/collect_nodes_statuses.py) /v1a/status StatusResource.render_GET always returns HTTP 200 with the status dump — no error branch, never sets a response code. The only failures observable here are transport-level (timeout / connection refused) → {"error": repr(e)}, plus upstream gateway 5xx from nginx/LB. Checks if "error" in data, otherwise parses the body with Node.from_status_dict. Since the endpoint itself never returns an error status, it only reaches _decode_error_body for upstream gateway errors (non-JSON → {"error": ..., "body": ...}, still caught by the "error" check).
GetHealthcheck (usecases/get_healthcheck.py via HealthcheckGateway) /v1a/health HealthcheckResource returns 200 when healthy and 503 when unhealthy, but the verdict is always in the body (status: pass/fail). It also accepts strict_status_code=1 to force 200 even when failing. Reads health_response["status"] for the pass/warn/fail nuance, so it needs the 503 body returned as-is — which _decode_error_body does (the body is a JSON object). Retries are disabled here so the legitimate 503 is returned immediately.

Changes

  • gateways/clients/hathor_core_client.py — retry loop in HathorCoreAsyncClient.get(); MAX_RETRIES / RETRY_DELAY / RETRYABLE_STATUS_CODES constants; _decode_error_body; retry opt-out flag; per-attempt timeout docstring
  • gateways/healthcheck_gateway.py — pass retry=False for the health endpoint
  • tests/unit/gateways/clients/test_hathor_core_client.py — new TestHathorCoreAsyncClientGet covering success, retry-then-success, single session reuse, retry exhaustion, non-retryable 4xx, JSON/non-JSON error bodies, the retry=False opt-out, and network exceptions
  • tests/unit/gateways/test_healthcheck_gateway.py — assert retry=False is passed for the health call

Summary by CodeRabbit

  • New Features
    • Added transient retry support for gateway requests via an opt-in retry mode, with per-attempt timeout handling.
  • Bug Fixes
    • Healthcheck requests now explicitly avoid retries to preserve the healthcheck timeout budget.
    • Improved consistent error normalization for non-JSON/JSON failure responses, including behavior when retries are exhausted.
  • Tests
    • Expanded unit coverage for retry success, retry exhaustion, non-retryable 4xx behavior, non-JSON vs JSON error handling, and network exception scenarios.

Copilot AI review requested due to automatic review settings April 24, 2026 15:53
@luislhl
luislhl requested a review from r4mmer as a code owner April 24, 2026 15:53
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds retry handling to HathorCoreAsyncClient.get for retryable gateway statuses, normalizes error responses, disables retries for the healthcheck path, and expands async tests for success, retries, non-JSON bodies, retry opt-out, and exceptions.

Changes

HTTP Retry Logic & Tests

Layer / File(s) Summary
Client retry behavior
gateways/clients/hathor_core_client.py
Adds retry constants, per-attempt timeout handling, bounded retry loops, JSON success handling, and normalized error-body decoding for terminal failures.
Async unit tests for get
tests/unit/gateways/clients/test_hathor_core_client.py
Adds async helpers and coverage for 200 success, retry and exhaustion paths, non-JSON bodies, retry=False, and request exceptions.
Healthcheck retry opt-out
gateways/healthcheck_gateway.py, tests/unit/gateways/test_healthcheck_gateway.py
Passes retry=False through the healthcheck gateway and updates the call expectation in the unit test.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through retries, soft and slow,
With JSON treats and warnings in tow.
The healthcheck said “no second try,”
So I nodded, twitched, and let it by.
Thump! The client landed just right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding retry logic for 5xx gateway errors in the async HTTP client.
Docstring Coverage ✅ Passed Docstring coverage is 86.36% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/retry-on-5xx-gateway-errors

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds retry behavior to the async Hathor Core HTTP client to avoid noisy/log-spammy failures when upstream gateways return transient 5xx (502/503/504) HTML responses, and introduces unit tests covering the new retry scenarios.

Changes:

  • Implemented bounded retry logic in HathorCoreAsyncClient.get() for {502, 503, 504} with a fixed delay.
  • Ensured retryable 5xx responses consume the body and avoid JSON parsing exceptions, reducing duplicate logging.
  • Added async unit tests validating success, retry-then-success, retry-exhausted, non-retryable 4xx, and exception paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
gateways/clients/hathor_core_client.py Adds retry loop/constants to suppress repeated logs/JSON parsing failures on transient gateway 5xx responses.
tests/unit/gateways/clients/test_hathor_core_client.py Adds async unit tests to validate retry behavior and logging expectations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateways/clients/hathor_core_client.py Outdated
Comment thread gateways/clients/hathor_core_client.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/unit/gateways/clients/test_hathor_core_client.py (1)

62-97: Optional: tighten return-value assertions.

Two minor gaps in the new tests — behavior is still well-covered, but these would pin down the contract more precisely:

  • Line 75: consider asserting the exact shape documented in the PR description, e.g. assert result == {"error": "status 502"}, so a future change to the error dict format is caught here.
  • Lines 86-97 (test_get_no_retry_on_4xx): the returned value is never asserted. Since the 4xx path still calls response.json(...) and returns its result (per the file under review, line 100), asserting the returned dict (or at least that it matches the mocked json_data) would document that non-retryable 4xx responses still bypass the error-dict path.
📝 Proposed assertions
-        assert "error" in result
+        assert result == {"error": "status 502"}
         client = HathorCoreAsyncClient("http://test.node")
         with patch.object(client, "log") as mock_log:
-            await client.get("/v1a/missing")
+            result = await client.get("/v1a/missing")

         mock_sleep.assert_not_called()
         mock_log.warning.assert_called_once()
+        assert result is None  # or whatever the 4xx contract is
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/gateways/clients/test_hathor_core_client.py` around lines 62 - 97,
Update the two tests to tighten return-value assertions: in
test_get_logs_warning_after_all_retries_exhausted, assert the exact returned
error dict (e.g. assert result == {"error": "status 502"}) rather than only
checking "error" in result; in test_get_no_retry_on_4xx, assert the value
returned by calling HathorCoreAsyncClient.get (the mocked response.json payload)
so the 4xx path that returns the response.json is validated (use the same
json_data supplied to _make_response). Ensure you reference the existing test
functions test_get_logs_warning_after_all_retries_exhausted and
test_get_no_retry_on_4xx and the HathorCoreAsyncClient.get behavior when adding
these assertions.
gateways/clients/hathor_core_client.py (1)

75-103: Retry loop LGTM; a couple of observations on cadence & symmetry.

Control flow is correct: up to 3 attempts, 2 sleeps on persistent failure, a single consolidated WARNING on exhaustion, and the body is drained each attempt as stated in the PR description. Two minor things to consider (neither blocking):

  1. Polling cadence impact. CollectNodesStatuses.collect iterates HATHOR_NODES sequentially on a 1s cycle. With MAX_RETRIES=2 and RETRY_DELAY=1.0, a single persistently-failing node adds up to ~2s to each cycle (and N flaky nodes compound linearly). That may be acceptable here — it still reduces log spam from 3×N/cycle to 1×N/cycle — but worth confirming this is within the tolerance of the polling interval and any downstream alerting. Consider a shorter RETRY_DELAY (e.g. 0.2–0.5s) or a budget-based cap if cycle time matters.

  2. Retry symmetry with network exceptions. The except Exception arm returns immediately, so transient connection errors (aiohttp.ClientConnectionError, timeouts, DNS blips) — which in practice are as recoverable as 502/503/504 — are not retried. If the goal is "reduce transient noise", retrying on a narrow set of network exceptions would be a natural extension. Explicitly out of scope for this PR per the description; just flagging for a possible follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateways/clients/hathor_core_client.py` around lines 75 - 103, The loop
currently treats HTTP retryable statuses but returns immediately on any
exception, which prevents retry symmetry; change the except block in the GET
loop so it only treats network/timeout exceptions (e.g., aiohttp.ClientError,
asyncio.TimeoutError) as retryable and follows the same MAX_RETRIES/RETRY_DELAY
logic (sleep and continue while attempt < MAX_RETRIES, log a single warning on
exhaustion using the same "hathor_core_error" semantics), and consider reducing
the default RETRY_DELAY used by MAX_RETRIES (or make it configurable) so
CollectNodesStatuses.collect (which iterates HATHOR_NODES) doesn't incur
multi-second stalls per node.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@gateways/clients/hathor_core_client.py`:
- Around line 75-103: The loop currently treats HTTP retryable statuses but
returns immediately on any exception, which prevents retry symmetry; change the
except block in the GET loop so it only treats network/timeout exceptions (e.g.,
aiohttp.ClientError, asyncio.TimeoutError) as retryable and follows the same
MAX_RETRIES/RETRY_DELAY logic (sleep and continue while attempt < MAX_RETRIES,
log a single warning on exhaustion using the same "hathor_core_error"
semantics), and consider reducing the default RETRY_DELAY used by MAX_RETRIES
(or make it configurable) so CollectNodesStatuses.collect (which iterates
HATHOR_NODES) doesn't incur multi-second stalls per node.

In `@tests/unit/gateways/clients/test_hathor_core_client.py`:
- Around line 62-97: Update the two tests to tighten return-value assertions: in
test_get_logs_warning_after_all_retries_exhausted, assert the exact returned
error dict (e.g. assert result == {"error": "status 502"}) rather than only
checking "error" in result; in test_get_no_retry_on_4xx, assert the value
returned by calling HathorCoreAsyncClient.get (the mocked response.json payload)
so the 4xx path that returns the response.json is validated (use the same
json_data supplied to _make_response). Ensure you reference the existing test
functions test_get_logs_warning_after_all_retries_exhausted and
test_get_no_retry_on_4xx and the HathorCoreAsyncClient.get behavior when adding
these assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69fcaa89-8b69-47fc-a44a-d152f2cd3cca

📥 Commits

Reviewing files that changed from the base of the PR and between 731d6ff and 1a62cc8.

📒 Files selected for processing (2)
  • gateways/clients/hathor_core_client.py
  • tests/unit/gateways/clients/test_hathor_core_client.py

@luislhl luislhl self-assigned this Apr 28, 2026
@luislhl luislhl moved this from Todo to In Progress (WIP) in Hathor Network Apr 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@gateways/clients/hathor_core_client.py`:
- Around line 82-99: The current error branch returns a synthetic {"error":
"status X"} for any response.status > 299, dropping JSON error payloads; update
the logic in the error handling around RETRYABLE_STATUS_CODES / MAX_RETRIES so
that for non-retryable statuses you parse and return the original JSON body via
response.json(content_type=content_type) (and still log via self.log.warning
with path/status/body), while for retryable statuses (checked against
self.RETRYABLE_STATUS_CODES and attempt < self.MAX_RETRIES) you keep the
sleep/continue behavior and only synthesize the fallback {"error": f"status
{response.status}"} after retries are exhausted; adjust uses of response.text()
vs response.json(...) accordingly so callers receive server-provided JSON when
available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef8bda0d-4075-461c-9339-14c7e3414494

📥 Commits

Reviewing files that changed from the base of the PR and between 1a62cc8 and 4ca3310.

📒 Files selected for processing (2)
  • gateways/clients/hathor_core_client.py
  • tests/unit/gateways/clients/test_hathor_core_client.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/unit/gateways/clients/test_hathor_core_client.py

Comment thread gateways/clients/hathor_core_client.py Outdated
@luislhl luislhl moved this from In Progress (WIP) to In Progress (Done) in Hathor Network May 13, 2026
Comment thread gateways/clients/hathor_core_client.py
Comment thread gateways/clients/hathor_core_client.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
gateways/clients/hathor_core_client.py (1)

80-102: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

return {"error": "max retries exceeded"} at Line 102 is unreachable.

The loop runs attempt over 0..MAX_RETRIES. On the final iteration attempt == MAX_RETRIES, so attempt < self.MAX_RETRIES is False and the retryable branch can't continue; that iteration always falls through to the return self._decode_error_body(...) at Line 97 (and success returns at Line 83). Therefore the for loop never exits without returning, making Line 102 dead code. This also makes its "max retries exceeded" message misleading, since exhausted retries actually return the decoded upstream body, not this payload.

The retry/decode logic itself is correct.

♻️ Remove the unreachable fallback
         except Exception as e:
             self.log.error("hathor_core_error", path=path, error=repr(e))
             return {"error": repr(e)}
-
-        return {"error": "max retries exceeded"}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateways/clients/hathor_core_client.py` around lines 80 - 102, The fallback
return at the end of the retry loop in hathor_core_client.py is unreachable
because all paths in the session.get loop already return either on success, on a
non-retryable failure, or on the final exhausted retry. Remove the dead `return
{"error": "max retries exceeded"}` from the retry handling in the client method
that wraps `session.get`, keeping the existing success, warning, and exception
paths unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@gateways/clients/hathor_core_client.py`:
- Around line 80-102: The fallback return at the end of the retry loop in
hathor_core_client.py is unreachable because all paths in the session.get loop
already return either on success, on a non-retryable failure, or on the final
exhausted retry. Remove the dead `return {"error": "max retries exceeded"}` from
the retry handling in the client method that wraps `session.get`, keeping the
existing success, warning, and exception paths unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e7187f8-657f-45b0-b67c-caa82e5ce0ef

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca3310 and 43e11e8.

📒 Files selected for processing (2)
  • gateways/clients/hathor_core_client.py
  • tests/unit/gateways/clients/test_hathor_core_client.py

luislhl and others added 7 commits June 28, 2026 22:54
On transient fullnode unreachability (502/503/504), the daemon was
generating three log entries per node per 1-second polling cycle:
  1. WARNING hathor_core_error  (status=502, body=html)
  2. ERROR   hathor_core_error  (ContentTypeError from response.json())
  3. WARNING collect_status_error

Root cause: HathorCoreAsyncClient.get() immediately logged a warning on
any non-2xx, then called response.json() on an HTML body which raised a
ContentTypeError, triggering a second log in the except branch, which
in turn caused a third log in CollectNodesStatuses._send().

Fix: wrap the request in a retry loop (MAX_RETRIES=2, RETRY_DELAY=1s)
for RETRYABLE_STATUS_CODES={502,503,504}.  Only log after all retries are
exhausted, and return {"error": "status 502"} directly instead of
letting the JSON parse exception propagate.

Result:
- A transient 502 that recovers within one retry window => zero logs
- A persistent 502 after all retries => exactly one WARNING

Tests: add TestHathorCoreAsyncClientGet covering success, 502->200
retry (no warning), exhausted retries (single warning), no-retry on
4xx, and no-retry on network exception.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
For error responses we now return the server-provided JSON body when the
body really is JSON, and otherwise return the status plus the raw body
(GCP load balancer 5xx can come back as plain text/HTML). This avoids
overriding error responses with a synthetic {"error": "status X"} and
keeps the original error details for callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The retry logic targets transient gateway errors (502/503/504). The
fullnode health endpoint, however, returns 503 as a legitimate answer
(the body still carries the 'status'), so retrying it only wastes the
tight healthcheck timeout budget. Add a 'retry' flag to
HathorCoreAsyncClient.get (default True) and disable it in the
healthcheck gateway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@luislhl
luislhl force-pushed the feat/retry-on-5xx-gateway-errors branch from 475b645 to b63e5a5 Compare June 29, 2026 01:55
@luislhl luislhl moved this from In Progress (Done) to In Review (WIP) in Hathor Network Jul 1, 2026
@luislhl
luislhl requested a review from raul-oliveira July 7, 2026 16:09
@luislhl
luislhl merged commit a3f98bc into main Jul 10, 2026
5 checks passed
@github-project-automation github-project-automation Bot moved this from In Review (WIP) to Waiting to be deployed in Hathor Network Jul 10, 2026
@luislhl
luislhl deleted the feat/retry-on-5xx-gateway-errors branch July 10, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Waiting to be deployed

Development

Successfully merging this pull request may close these issues.

4 participants