From 222cbfc01d1872a8fc4370c42f65144df94c77b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Galv=C3=A1nek?= Date: Thu, 18 Jun 2026 11:14:45 +0200 Subject: [PATCH 1/4] fix(base): log retried HTTP errors at WARNING, ERROR only on real failure get_data logged every HTTPError at ERROR before deciding whether to retry, so successfully-retried 429/5xx throttling produced ERROR noise (~1600/day from OpenSea in prod) despite zero actual failures, and the same request was double-logged (ERROR then WARNING). Now the ERROR log fires only on the genuine-failure return path (retries exhausted, non-retryable status, or no sleep provider). Retryable errors that will be retried log a single WARNING. This makes ERROR on the fetch logger a reliable signal of real fetch failures. Related to crypkit_app/microservices#2897 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Tyekqa8ioFUnJt9c4dye7K --- blockapi/test/v2/test_base.py | 58 +++++++++++++++++++++++++++++++++++ blockapi/v2/base.py | 23 ++++++++------ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/blockapi/test/v2/test_base.py b/blockapi/test/v2/test_base.py index 458822c0..980069f4 100644 --- a/blockapi/test/v2/test_base.py +++ b/blockapi/test/v2/test_base.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import MagicMock, patch import pytest @@ -117,3 +118,60 @@ def mocked_500_with_success_response(): def test_5xx_will_retry(customizable_api, mocked_500_with_success_response): response = customizable_api.get_data("test_method") assert response.status_code == 200 + + +@pytest.fixture() +def mocked_429_with_success_response(): + mocked_throttled = MagicMock() + mocked_throttled.status_code = 429 + mocked_throttled.raise_for_status.side_effect = HTTPError( + "test_method", 429, "exception", {}, None + ) + mocked_throttled.json.return_value = {} + mocked_throttled.headers = {} + + mocked_success = MagicMock() + mocked_success.status_code = 200 + mocked_success.json.return_value = {} + mocked_success.headers = {} + + with patch('blockapi.v2.base.CustomizableBlockchainApi._get_response') as patched: + patched.side_effect = [ + mocked_throttled, + mocked_success, + ] + yield patched + + +def test_429_retried_logs_warning_not_error( + customizable_api, mocked_429_with_success_response, caplog +): + with caplog.at_level(logging.WARNING, logger='blockapi.v2.base'): + response = customizable_api.get_data("test_method") + + assert response.status_code == 200 + assert not any(r.levelno >= logging.ERROR for r in caplog.records) + assert any(r.levelno == logging.WARNING for r in caplog.records) + + +@pytest.fixture() +def mocked_429_only_response(): + mocked_throttled = MagicMock() + mocked_throttled.status_code = 429 + mocked_throttled.raise_for_status.side_effect = HTTPError( + "test_method", 429, "exception", {}, None + ) + mocked_throttled.json.return_value = {} + mocked_throttled.headers = {} + + with patch('blockapi.v2.base.CustomizableBlockchainApi._get_response') as patched: + patched.side_effect = [mocked_throttled] * 5 + yield patched + + +def test_429_exhausted_logs_error(customizable_api, mocked_429_only_response, caplog): + with caplog.at_level(logging.WARNING, logger='blockapi.v2.base'): + response = customizable_api.get_data("test_method") + + assert response.status_code == 429 + assert any(r.levelno == logging.ERROR for r in caplog.records) diff --git a/blockapi/v2/base.py b/blockapi/v2/base.py index c65926fb..7b74b22e 100644 --- a/blockapi/v2/base.py +++ b/blockapi/v2/base.py @@ -113,15 +113,17 @@ def get_data( self.sleep_provider.sleep(self.base_url, seconds=sleep_seconds) continue except HTTPError: - logger.error(f"Request failed with http error: {response.status_code}") - retries -= 1 - if ( - retries <= 0 - or (response.status_code < 500 and response.status_code != 429) - or not self.sleep_provider - ): + retryable = response.status_code == 429 or response.status_code >= 500 + + if retries <= 0 or not retryable or not self.sleep_provider: + # Genuine failure: out of retries, non-retryable status, + # or nothing to pace the retry with. This is the only path + # that surfaces an error to the caller, so log at ERROR. + logger.error( + f"Request failed with http error: {response.status_code}" + ) time = self._get_response_time(response.headers) return FetchResult( status_code=response.status_code, @@ -137,9 +139,12 @@ def get_data( except ValueError: seconds = 60 + # Retryable error that will be retried, so this is expected + # noise (e.g. 429 throttling) rather than a failure: WARNING. logger.warning( - f'Too Many Requests: Will retry after {seconds}s sleep.' - f' Remaining attempts {retries}.' + f'Request failed with retryable http error' + f' {response.status_code}: will retry after {seconds}s' + f' sleep. Remaining attempts {retries}.' ) self.sleep_provider.sleep(self.base_url, seconds=seconds) continue From 91447694e7376fe0999e1b78f7c9263870d0956f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Galv=C3=A1nek?= Date: Thu, 18 Jun 2026 11:14:45 +0200 Subject: [PATCH 2/4] fix(opensea): ease request pacing from 4 rps to 4 reqs / 3s to cut 429s OpenSea returned HTTP 429 on ~14% of first-try requests at the 0.25s (4 rps) rate limit, slightly above our key's budget. Current real OpenSea load is only ~0.3-0.5 rps (flat, not bursty), so raise the pacing interval to 0.75s (4 requests per 3 seconds, ~1.33 rps) to keep requests well under the limit while retaining ample burst headroom. Related to crypkit_app/microservices#2897 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Tyekqa8ioFUnJt9c4dye7K --- blockapi/v2/api/nft/opensea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blockapi/v2/api/nft/opensea.py b/blockapi/v2/api/nft/opensea.py index 90636e3c..8b3223ec 100644 --- a/blockapi/v2/api/nft/opensea.py +++ b/blockapi/v2/api/nft/opensea.py @@ -75,7 +75,7 @@ class OpenSeaApi(BlockchainApi, INftProvider, INftParser): api_options = ApiOptions( blockchain=Blockchain.ETHEREUM, base_url='https://api.opensea.io/', - rate_limit=0.25, # 4 per second + rate_limit=0.75, # 4 requests per 3 seconds ) supported_requests = { From 546c0c7a2648f1ac1794a887f4ec21de457f2514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Galv=C3=A1nek?= Date: Thu, 18 Jun 2026 12:22:11 +0200 Subject: [PATCH 3/4] fix(base): surface response body in HTTP error reason _get_reason returned only the HTTP status phrase (e.g. "Service Temporarily Unavailable"), discarding the server's response body. Append the body (truncated to 500 chars) so errors carry the actual message. Harden MagicEden._should_retry to substring-match "SERVICE UNAVAILABLE" so the 503 retry still fires now that the reason may include a body. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013ndtG1bvGFomMdhosUADc3 --- blockapi/v2/api/nft/magic_eden.py | 2 +- blockapi/v2/base.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/blockapi/v2/api/nft/magic_eden.py b/blockapi/v2/api/nft/magic_eden.py index d701ba97..eef9df41 100644 --- a/blockapi/v2/api/nft/magic_eden.py +++ b/blockapi/v2/api/nft/magic_eden.py @@ -399,7 +399,7 @@ def _should_retry(self, data: FetchResult) -> bool: return False retry = bool( - [t for t in data.errors if str(t).upper() == 'SERVICE UNAVAILABLE'] + [t for t in data.errors if 'SERVICE UNAVAILABLE' in str(t).upper()] ) if retry: logger.warning('Service unavailable - will retry after long sleep') diff --git a/blockapi/v2/base.py b/blockapi/v2/base.py index 7b74b22e..27ff6c54 100644 --- a/blockapi/v2/base.py +++ b/blockapi/v2/base.py @@ -198,15 +198,19 @@ def _check_and_get_from_response(self, response: Response) -> Dict: def _get_reason(response): reason = response.reason if not reason and response.status_code >= 400: - return f'Error {response.status_code}' + reason = f'Error {response.status_code}' - if not isinstance(reason, bytes): - return reason - - try: - return reason.decode("utf-8") - except UnicodeDecodeError: - return reason.decode("iso-8859-1") + if isinstance(reason, bytes): + try: + reason = reason.decode("utf-8") + except UnicodeDecodeError: + reason = reason.decode("iso-8859-1") + + # Append the response body so the error carries the server's own + # message instead of just the HTTP status phrase. Truncated to keep + # logs readable. + body = (response.text or '').strip() + return f'{reason}: {body[:500]}' if body else reason @staticmethod def _raise_from_response(response: Response) -> None: From e1b404e45f9a0826c8202e5c7cc6b9d141b11ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Galv=C3=A1nek?= Date: Thu, 18 Jun 2026 12:59:11 +0200 Subject: [PATCH 4/4] test(opensea): update sleep-provider assertions to 0.75 pacing The OpenSea rate_limit was changed to 0.75 (4 req/3s) but the sleep-provider assertions in test_opensea.py still expected 0.25, breaking CI on master. Update the three assertions to match. Related to crypkit_app/microservices#2897 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Tyekqa8ioFUnJt9c4dye7K --- blockapi/test/v2/api/nft/test_opensea.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/blockapi/test/v2/api/nft/test_opensea.py b/blockapi/test/v2/api/nft/test_opensea.py index f7b6b1e0..f5182579 100644 --- a/blockapi/test/v2/api/nft/test_opensea.py +++ b/blockapi/test/v2/api/nft/test_opensea.py @@ -84,7 +84,7 @@ def test_fetch_ntfs( nfts = api.fetch_nfts(nfts_test_address) assert len(nfts.data) == 2 assert len(fake_sleep_provider.calls) - assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.25) + assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.75) def test_fetch_ntfs_error_response(requests_mock, api, fake_sleep_provider): @@ -96,7 +96,7 @@ def test_fetch_ntfs_error_response(requests_mock, api, fake_sleep_provider): nfts = api.fetch_nfts(nfts_test_address) assert len(nfts.data) == 0 assert len(fake_sleep_provider.calls) - assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.25) + assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.75) def test_fetch_offers( @@ -114,7 +114,7 @@ def test_fetch_offers( offers = api.fetch_offers(test_collection_slug) assert len(offers.data) == 2 assert len(fake_sleep_provider.calls) - assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.25) + assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.75) def test_fetch_offers_error_response(requests_mock, api, offers_response):