From 12eb42d940839e1568492fdf004e141a64a24d55 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 14:12:37 -0300 Subject: [PATCH] Normalize HTTP error handling --- bcb/currency.py | 166 ++++++++++++-------------------- bcb/http.py | 70 +++++++++++++- bcb/odata/framework.py | 68 +++++++++++-- bcb/sgs/__init__.py | 84 ++++++++-------- tests/test_async.py | 35 +++++++ tests/test_currency_negative.py | 33 +++++++ tests/test_http.py | 84 ++++++++++++++++ tests/test_odata.py | 53 ++++++++++ tests/test_sgs_negative.py | 21 ++++ 9 files changed, 461 insertions(+), 153 deletions(-) create mode 100644 tests/test_http.py diff --git a/bcb/currency.py b/bcb/currency.py index 0c57b14..eeac772 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -6,25 +6,23 @@ import threading from datetime import date, timedelta from io import BytesIO, StringIO -from typing import TYPE_CHECKING, Dict, List, Literal, NamedTuple, Union, overload +from typing import Dict, List, Literal, NamedTuple, Union, overload from urllib.parse import urlencode +import httpx import numpy as np import pandas as pd from lxml import html -from bcb.http import _CLIENT, _ASYNC_CLIENT -from bcb.exceptions import ( - BCBAPIError, - BCBAPINotFoundError, - BCBRateLimitError, - CurrencyNotFoundError, +from bcb.http import ( + _CLIENT, + _ASYNC_CLIENT, + raise_for_request_error, + raise_for_status, ) +from bcb.exceptions import BCBAPIError, CurrencyNotFoundError from bcb.utils import Date, DateInput -if TYPE_CHECKING: - import httpx - logger = logging.getLogger(__name__) """ @@ -165,28 +163,18 @@ def _currency_id_list( "method=exibeFormularioConsultaBoletim" ) logger.debug(f"Fetching currency ID list from {url1}") - res = _CLIENT.get(url1) + try: + res = _CLIENT.get(url1) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context="Currency ID list") logger.debug( f"Currency ID list response: status={res.status_code}, length={len(res.content)}" ) - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - "BCB API endpoint not found (404)", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - msg = f"BCB API Request error, status code = {res.status_code}" - raise BCBAPIError(msg, res.status_code) + raise_for_status( + res, + context="Currency ID list", + not_found_message="BCB API endpoint not found (404)", + ) doc = html.parse(BytesIO(res.content)).getroot() xpath = "//select[@name='ChkMoeda']/option" @@ -238,10 +226,10 @@ def _get_valid_currency_list( logger.debug(f"Fetching currency list from {url2}") try: res = _CLIENT.get(url2) - except Exception as ex: + except httpx.HTTPError as ex: # Connection error: retry same date up to 3 times if n >= 3: - raise ex + raise_for_request_error(ex, context="Currency list") logger.warning( f"Connection error fetching {url2}, retrying (attempt {n + 1}/3)" ) @@ -252,12 +240,12 @@ def _get_valid_currency_list( ) if res.status_code == 200: return res - else: - # Non-200 response (file not found for date): roll back to previous day - logger.debug( - f"Currency list not found for {_date}, rolling back to previous day" - ) - return _get_valid_currency_list(_date - timedelta(1), 0, max_rollback) + if res.status_code == 429 or res.status_code >= 500: + raise_for_status(res, context="Currency list") + + # Non-200 response (file not found for date): roll back to previous day + logger.debug(f"Currency list not found for {_date}, rolling back to previous day") + return _get_valid_currency_list(_date - timedelta(1), 0, max_rollback) def get_currency_list( @@ -347,13 +335,16 @@ def _fetch_symbol_response( cid = _get_currency_id(symbol) # Raises CurrencyNotFoundError if not found url = _currency_url(cid, start_date, end_date) logger.debug(f"Fetching currency data for {symbol} from {url.split('?')[0]}") - res = _CLIENT.get(url) + try: + res = _CLIENT.get(url) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context=f"Currency data for {symbol}") logger.debug( f"Currency data response: status={res.status_code}, length={len(res.content)}" ) # Handle HTML error response (e.g., no data for date range) - if res.headers["Content-Type"].startswith("text/html"): + if res.headers.get("Content-Type", "").startswith("text/html"): doc = html.parse(BytesIO(res.content)).getroot() xpath = "//div[@class='msgErro']" elm = doc.xpath(xpath)[0] @@ -363,27 +354,11 @@ def _fetch_symbol_response( msg = f"BCB API returned error: {x} - {symbol}" raise BCBAPIError(msg, status_code=400) - # Handle HTTP error responses - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - f"Currency data not found for {symbol}", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - raise BCBAPIError( - f"BCB API request failed with status {res.status_code}", - status_code=res.status_code, - ) + raise_for_status( + res, + context=f"Currency data for {symbol}", + not_found_message=f"Currency data not found for {symbol}", + ) return res @@ -689,25 +664,15 @@ async def _async_currency_id_list( "https://ptax.bcb.gov.br/ptax_internet/consultaBoletim.do?" "method=exibeFormularioConsultaBoletim" ) - res = await _ASYNC_CLIENT.get(url1) - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - "BCB API endpoint not found (404)", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - msg = f"BCB API Request error, status code = {res.status_code}" - raise BCBAPIError(msg, res.status_code) + try: + res = await _ASYNC_CLIENT.get(url1) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context="Currency ID list") + raise_for_status( + res, + context="Currency ID list", + not_found_message="BCB API endpoint not found (404)", + ) doc = html.parse(BytesIO(res.content)).getroot() xpath = "//select[@name='ChkMoeda']/option" @@ -732,17 +697,16 @@ async def _async_get_valid_currency_list( url2 = f"https://www4.bcb.gov.br/Download/fechamento/M{_date:%Y%m%d}.csv" try: res = await _ASYNC_CLIENT.get(url2) - except Exception as ex: + except httpx.HTTPError as ex: if n >= 3: - raise ex + raise_for_request_error(ex, context="Currency list") return await _async_get_valid_currency_list(_date, n + 1, max_rollback) if res.status_code == 200: return res - else: - return await _async_get_valid_currency_list( - _date - timedelta(1), 0, max_rollback - ) + if res.status_code == 429 or res.status_code >= 500: + raise_for_status(res, context="Currency list") + return await _async_get_valid_currency_list(_date - timedelta(1), 0, max_rollback) async def _async_get_currency_list( @@ -794,9 +758,12 @@ async def _async_fetch_symbol_response( """Async version of _fetch_symbol_response().""" cid = await _async_get_currency_id(symbol) url = _currency_url(cid, start_date, end_date) - res = await _ASYNC_CLIENT.get(url) + try: + res = await _ASYNC_CLIENT.get(url) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context=f"Currency data for {symbol}") - if res.headers["Content-Type"].startswith("text/html"): + if res.headers.get("Content-Type", "").startswith("text/html"): doc = html.parse(BytesIO(res.content)).getroot() xpath = "//div[@class='msgErro']" elm = doc.xpath(xpath)[0] @@ -806,26 +773,11 @@ async def _async_fetch_symbol_response( msg = f"BCB API returned error: {x} - {symbol}" raise BCBAPIError(msg, status_code=400) - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - f"Currency data not found for {symbol}", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - raise BCBAPIError( - f"BCB API request failed with status {res.status_code}", - status_code=res.status_code, - ) + raise_for_status( + res, + context=f"Currency data for {symbol}", + not_found_message=f"Currency data not found for {symbol}", + ) return res diff --git a/bcb/http.py b/bcb/http.py index a62b538..07b3a15 100644 --- a/bcb/http.py +++ b/bcb/http.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Callable, TypeVar +from typing import Callable, NoReturn, TypeVar import httpx from tenacity import ( @@ -11,6 +11,13 @@ wait_exponential, ) +from bcb.exceptions import ( + BCBAPIError, + BCBAPINotFoundError, + BCBAPIServerError, + BCBRateLimitError, +) + # Default timeout for all HTTP requests (seconds) DEFAULT_TIMEOUT = 30.0 @@ -82,6 +89,67 @@ def close_async_client() -> None: T = TypeVar("T") +def _raise_error( + error_cls: type[Exception], + message: str, + status_code: int, +) -> NoReturn: + """Raise project exceptions with or without an HTTP status constructor.""" + if issubclass(error_cls, BCBAPIError): + raise error_cls(message, status_code) + raise error_cls(message) + + +def raise_for_status( + response: httpx.Response, + *, + context: str, + expected_status: int | tuple[int, ...] = 200, + error_cls: type[Exception] = BCBAPIError, + not_found_cls: type[Exception] = BCBAPINotFoundError, + rate_limit_cls: type[Exception] = BCBRateLimitError, + server_error_cls: type[Exception] = BCBAPIServerError, + rate_limit_message: str | None = None, + not_found_message: str | None = None, + server_error_message: str | None = None, + error_message: str | None = None, +) -> None: + """Raise a consistent project exception for unexpected HTTP statuses.""" + expected = ( + (expected_status,) if isinstance(expected_status, int) else expected_status + ) + status_code = response.status_code + if status_code in expected: + return + + if status_code == 429: + message = ( + rate_limit_message or "BCB API rate limit exceeded. Please try again later." + ) + _raise_error(rate_limit_cls, message, status_code) + if status_code == 404: + message = not_found_message or f"{context} not found (status 404)" + _raise_error(not_found_cls, message, status_code) + if status_code >= 500: + message = ( + server_error_message or f"{context} server error (status {status_code})" + ) + _raise_error(server_error_cls, message, status_code) + + message = error_message or f"{context} request failed with status {status_code}" + _raise_error(error_cls, message, status_code) + + +def raise_for_request_error( + exc: httpx.HTTPError, + *, + context: str, + error_cls: type[Exception] = BCBAPIError, +) -> NoReturn: + """Raise a consistent project exception for HTTP client failures.""" + _raise_error(error_cls, f"{context} request failed: {exc}", status_code=0) + + def with_retry(func: Callable[..., T]) -> Callable[..., T]: """Decorator to add automatic retry with exponential backoff to any function. diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index a9af401..728e83b 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -1,16 +1,17 @@ from __future__ import annotations +import json import logging import threading from io import BytesIO from typing import Any, Optional, Union +from urllib.parse import quote +import httpx from lxml import etree -import json -from urllib.parse import quote from typing_extensions import Self -from bcb.http import _CLIENT, _ASYNC_CLIENT +from bcb.http import _ASYNC_CLIENT, _CLIENT, raise_for_request_error, raise_for_status from bcb.exceptions import ODataError logger = logging.getLogger(__name__) @@ -283,10 +284,23 @@ def __init__(self, url: str) -> None: def _load_document(self) -> None: logger.debug(f"Fetching OData metadata from {self.url}") - res = _CLIENT.get(self.url) + try: + res = _CLIENT.get(self.url) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData metadata {self.url}", error_cls=ODataError + ) logger.debug( f"OData metadata response: status={res.status_code}, length={len(res.content)}" ) + raise_for_status( + res, + context=f"OData metadata {self.url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) self.doc = etree.parse(BytesIO(res.content)) def _parse_entity(self, entity_element: Any, namespace: str) -> ODataEntity: @@ -378,7 +392,20 @@ class ODataService: def __init__(self, url: str) -> None: self.url = url - res = _CLIENT.get(self.url) + try: + res = _CLIENT.get(self.url) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData service {self.url}", error_cls=ODataError + ) + raise_for_status( + res, + context=f"OData service {self.url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) self.api_data: dict[str, Any] = json.loads(res.text) self.endpoints: list[ODataEndPoint] = [ ODataEndPoint(**x) for x in self.api_data["value"] @@ -541,7 +568,21 @@ async def async_text(self) -> str: params["@" + (p.name or "")] = p.format(val) qs = "&".join([f"{quote(k)}={quote(str(v))}" for k, v in params.items()]) headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} - res = await _ASYNC_CLIENT.get(self.odata_url() + "?" + qs, headers=headers) + url = self.odata_url() + try: + res = await _ASYNC_CLIENT.get(url + "?" + qs, headers=headers) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData query {url}", error_cls=ODataError + ) + raise_for_status( + res, + context=f"OData query {url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) return res.text async def async_collect(self) -> Any: @@ -560,10 +601,23 @@ def text(self) -> str: headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} url = self.odata_url() logger.debug(f"Fetching OData query from {url}") - res = _CLIENT.get(url + "?" + qs, headers=headers) + try: + res = _CLIENT.get(url + "?" + qs, headers=headers) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData query {url}", error_cls=ODataError + ) logger.debug( f"OData query response: status={res.status_code}, length={len(res.text)}" ) + raise_for_status( + res, + context=f"OData query {url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) return res.text def show(self) -> None: diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index 9604a35..a60e1dc 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -18,10 +18,16 @@ overload, ) +import httpx import pandas as pd -from bcb.http import _CLIENT, _ASYNC_CLIENT -from bcb.exceptions import BCBRateLimitError, SGSError +from bcb.http import ( + _CLIENT, + _ASYNC_CLIENT, + raise_for_request_error, + raise_for_status, +) +from bcb.exceptions import SGSError from bcb.utils import Date, DateInput logger = logging.getLogger(__name__) @@ -178,6 +184,30 @@ def _get_url_and_payload( return url, payload +def _raise_sgs_response_error(res: httpx.Response, code: int) -> None: + if res.status_code == 429: + raise_for_status(res, context=f"SGS time series code={code}") + + try: + res_json = json.loads(res.text) + except json.JSONDecodeError: + res_json = {} + + if "error" in res_json: + raise SGSError(f"BCB error: {res_json['error']}") + if "erro" in res_json: + raise SGSError(f"BCB error: {res_json['erro']['detail']}") + + raise_for_status( + res, + context=f"SGS time series code={code}", + error_cls=SGSError, + not_found_cls=SGSError, + server_error_cls=SGSError, + error_message=f"Download error: code = {code}", + ) + + def _format_df(df: pd.DataFrame, code: SGSCode, freq: Optional[str]) -> pd.DataFrame: cns = {"data": "Date", "valor": code.name, "datafim": "enddate"} df = df.rename(columns=cns) @@ -336,27 +366,16 @@ def get_json( """ url, payload = _get_url_and_payload(code, start, end, last) logger.debug(f"Fetching SGS time series code={code} from {url.split('/dados')[0]}") - res = _CLIENT.get(url, params=payload) - logger.debug(f"SGS response: status={res.status_code}, length={len(res.text)}") - - # Check for rate limiting first - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, + try: + res = _CLIENT.get(url, params=payload) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"SGS time series code={code}", error_cls=SGSError ) + logger.debug(f"SGS response: status={res.status_code}, length={len(res.text)}") if res.status_code != 200: - try: - res_json = json.loads(res.text) - except json.JSONDecodeError: - res_json = {} - - if "error" in res_json: - raise SGSError(f"BCB error: {res_json['error']}") - elif "erro" in res_json: - raise SGSError(f"BCB error: {res_json['erro']['detail']}") - raise SGSError(f"Download error: code = {code}") + _raise_sgs_response_error(res, code) return str(res.text) @@ -396,29 +415,18 @@ async def async_get_json( logger.debug( f"Fetching SGS time series (async) code={code} from {url.split('/dados')[0]}" ) - res = await _ASYNC_CLIENT.get(url, params=payload) + try: + res = await _ASYNC_CLIENT.get(url, params=payload) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"SGS time series code={code}", error_cls=SGSError + ) logger.debug( f"SGS (async) response: status={res.status_code}, length={len(res.text)}" ) - # Check for rate limiting first - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code != 200: - try: - res_json = json.loads(res.text) - except json.JSONDecodeError: - res_json = {} - - if "error" in res_json: - raise SGSError(f"BCB error: {res_json['error']}") - elif "erro" in res_json: - raise SGSError(f"BCB error: {res_json['erro']['detail']}") - raise SGSError(f"Download error: code = {code}") + _raise_sgs_response_error(res, code) return str(res.text) diff --git a/tests/test_async.py b/tests/test_async.py index dd069d0..0deefd5 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -10,6 +10,7 @@ from bcb import currency, sgs from bcb.odata.api import Expectativas +from bcb.exceptions import BCBRateLimitError, ODataError from tests.conftest import ( CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV, @@ -92,6 +93,16 @@ async def test_async_get_text_output(httpx_mock): assert "data" in result +async def test_async_get_json_rate_limit_raises(httpx_mock): + httpx_mock.add_response( + url=SGS_CODE_URL, + status_code=429, + ) + + with pytest.raises(BCBRateLimitError): + await sgs.async_get_json(1) + + # --------------------------------------------------------------------------- # Currency async tests # --------------------------------------------------------------------------- @@ -172,6 +183,30 @@ async def test_odata_query_async_text(httpx_mock): assert "value" in result +async def test_odata_query_async_status_error_raises(httpx_mock): + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/", + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/$metadata", + content=ODATA_METADATA_XML, + status_code=200, + ) + httpx_mock.add_response( + url=re.compile(r".*ExpectativasMercadoAnuais.*"), + text="server error", + status_code=500, + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="OData query"): + await ep.query().limit(1).async_text() + + async def test_odata_query_async_collect(httpx_mock): """Test ODataQuery.async_collect() returns DataFrame.""" httpx_mock.add_response( diff --git a/tests/test_currency_negative.py b/tests/test_currency_negative.py index 8572362..544393d 100644 --- a/tests/test_currency_negative.py +++ b/tests/test_currency_negative.py @@ -6,6 +6,7 @@ import re from datetime import datetime +import httpx import pytest from bcb import currency @@ -56,6 +57,38 @@ def test_get_currency_id_list_500_raises(httpx_mock): currency._currency_id_list() +def test_get_currency_id_list_connection_error_raises(httpx_mock): + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=PTAX_ID_LIST_URL, + ) + + with pytest.raises(BCBAPIError, match="Currency ID list"): + currency._currency_id_list() + + +def test_fetch_symbol_timeout_error_raises(httpx_mock): + from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV + + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + ) + httpx_mock.add_exception( + httpx.TimeoutException("request timed out"), + url=PTAX_RATE_URL, + ) + + with pytest.raises(BCBAPIError, match="Currency data for USD"): + currency._fetch_symbol_response("USD", START, END) + + def test_fetch_symbol_404_raises(httpx_mock): """Test that 404 when fetching rates raises BCBAPINotFoundError.""" from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..7ccfda9 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,84 @@ +"""Shared HTTP error handling tests.""" + +import httpx +import pytest + +from bcb.exceptions import ( + BCBAPIError, + BCBAPINotFoundError, + BCBAPIServerError, + BCBRateLimitError, + ODataError, + SGSError, +) +from bcb.http import raise_for_request_error, raise_for_status + + +def make_response(status_code: int) -> httpx.Response: + request = httpx.Request("GET", "https://example.test/resource") + return httpx.Response(status_code, request=request) + + +def test_raise_for_status_allows_expected_status() -> None: + raise_for_status(make_response(200), context="Example") + + +@pytest.mark.parametrize( + ("status_code", "error_cls"), + [ + (429, BCBRateLimitError), + (404, BCBAPINotFoundError), + (500, BCBAPIServerError), + ], +) +def test_raise_for_status_maps_common_http_statuses( + status_code: int, error_cls: type[BCBAPIError] +) -> None: + with pytest.raises(error_cls) as exc_info: + raise_for_status(make_response(status_code), context="Example") + + assert exc_info.value.status_code == status_code + + +def test_raise_for_status_maps_generic_client_error() -> None: + with pytest.raises(BCBAPIError) as exc_info: + raise_for_status(make_response(400), context="Example") + + assert exc_info.value.status_code == 400 + assert "Example" in str(exc_info.value) + + +def test_raise_for_status_supports_endpoint_specific_exceptions() -> None: + with pytest.raises(SGSError, match="SGS unavailable"): + raise_for_status( + make_response(503), + context="SGS", + server_error_cls=SGSError, + server_error_message="SGS unavailable", + ) + + +@pytest.mark.parametrize( + "error", + [ + httpx.ConnectError("connection failed"), + httpx.TimeoutException("request timed out"), + ], +) +def test_raise_for_request_error_maps_transport_failures( + error: httpx.HTTPError, +) -> None: + with pytest.raises(BCBAPIError) as exc_info: + raise_for_request_error(error, context="Example") + + assert exc_info.value.status_code == 0 + assert "Example request failed" in str(exc_info.value) + + +def test_raise_for_request_error_supports_endpoint_specific_exceptions() -> None: + with pytest.raises(ODataError, match="OData request failed"): + raise_for_request_error( + httpx.ConnectError("offline"), + context="OData", + error_cls=ODataError, + ) diff --git a/tests/test_odata.py b/tests/test_odata.py index e626360..135ebdd 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -1,6 +1,7 @@ import re from datetime import datetime +import httpx import pandas as pd import pytest @@ -64,6 +65,58 @@ def test_invalid_endpoint_raises(httpx_mock): api.get_endpoint("DoesNotExist") +def test_service_root_status_error_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text="service unavailable", + status_code=503, + ) + + with pytest.raises(ODataError, match="OData service"): + Expectativas() + + +def test_service_root_connection_error_raises_odata_error(httpx_mock): + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=EXPECTATIVAS_BASE_URL, + ) + + with pytest.raises(ODataError, match="OData service"): + Expectativas() + + +def test_metadata_status_error_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=EXPECTATIVAS_METADATA_URL, + text="metadata unavailable", + status_code=404, + ) + + with pytest.raises(ODataError, match="OData metadata"): + Expectativas() + + +def test_query_status_error_raises_odata_error(httpx_mock): + add_service_mocks(httpx_mock) + httpx_mock.add_response( + url=ENTITY_URL_PATTERN, + text="too many requests", + status_code=429, + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="rate limit"): + ep.query().limit(1).collect() + + # --------------------------------------------------------------------------- # ODataProperty operator overloading # --------------------------------------------------------------------------- diff --git a/tests/test_sgs_negative.py b/tests/test_sgs_negative.py index 7e76dab..d2f272e 100644 --- a/tests/test_sgs_negative.py +++ b/tests/test_sgs_negative.py @@ -6,6 +6,7 @@ import json import re +import httpx import pytest from bcb import sgs @@ -51,6 +52,26 @@ def test_get_json_500_raises(httpx_mock): sgs.get_json(1) +def test_get_json_connection_error_raises(httpx_mock): + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=SGS_CODE_URL, + ) + + with pytest.raises(SGSError, match="SGS time series"): + sgs.get_json(1) + + +def test_get_json_timeout_error_raises(httpx_mock): + httpx_mock.add_exception( + httpx.TimeoutException("request timed out"), + url=SGS_CODE_URL, + ) + + with pytest.raises(SGSError, match="SGS time series"): + sgs.get_json(1) + + # --------------------------------------------------------------------------- # Malformed data (JSON) # ---------------------------------------------------------------------------