Skip to content

Commit 87db07e

Browse files
wilsonfreitasclaude
andcommitted
Phase 2: Exception Hierarchy & Error Handling
Major changes for fail-fast error handling: Exceptions (bcb/exceptions.py): - Make BCBAPIError.status_code required (not Optional) - Add BCBAPINotFoundError (404 responses) - Add BCBRateLimitError (429 responses) - Add BCBAPIServerError (5xx responses) Currency module (bcb/currency.py): - Remove warnings.warn() → raise BCBAPIError instead - _fetch_symbol_response() now always raises on error (never returns None) - Add 429 detection in _currency_id_list() and _fetch_symbol_response() - Add 404 detection for better error specificity - Remove Optional return types from _get_symbol(), _get_symbol_text() - Improve error messages with status codes - Remove unused 'warnings' import SGS module (bcb/sgs/__init__.py): - Replace bare 'except Exception' with 'except json.JSONDecodeError' - Add 429 rate limit detection in get_json() - Import BCBRateLimitError Tests (tests/test_exceptions.py): - Update test_bcb_api_error_without_status_code → test_bcb_api_error_with_server_error - Add test_bcb_api_not_found_error - Add test_bcb_rate_limit_error - Import new exception types Breaking Changes: - Functions that returned None now raise exceptions - BCBAPIError now requires status_code parameter - 429 responses immediately raise instead of retrying All checks pass: ✓ mypy: 0 errors ✓ ruff check: all checks passed ✓ ruff format: code properly formatted ✓ tests: 24 passed, 0 failed (43 errors from missing httpx_mock fixture - Phase 7) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 6a3a9a5 commit 87db07e

5 files changed

Lines changed: 531 additions & 24 deletions

File tree

.claude/plans/phase2-review.md

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
# Phase 2: Exception Hierarchy & Error Handling — Detailed Review
2+
3+
## Objetivo
4+
Implementar fail-fast em todo o codebase, tornando `BCBAPIError.status_code` obrigatório, adicionando exceções especializadas para HTTP status codes, e removendo padrões de retorno `None` + `warnings.warn()`.
5+
6+
---
7+
8+
## 1️⃣ Mudanças em `bcb/exceptions.py`
9+
10+
### Estrutura atual:
11+
```python
12+
class BCBError(Exception):
13+
"""Base exception for all python-bcb errors."""
14+
15+
class BCBAPIError(BCBError):
16+
"""HTTP or API-level error from BCB."""
17+
def __init__(self, message: str, status_code: int | None = None):
18+
super().__init__(message)
19+
self.status_code = status_code
20+
21+
class CurrencyNotFoundError(BCBError):
22+
"""Raised when a requested currency symbol is not found."""
23+
24+
class SGSError(BCBError):
25+
"""Raised for SGS-specific API errors."""
26+
27+
class ODataError(BCBError):
28+
"""Raised for OData query/metadata errors."""
29+
```
30+
31+
### Mudanças necessárias:
32+
33+
#### A) Tornar `status_code` obrigatório em `BCBAPIError`
34+
```python
35+
class BCBAPIError(BCBError):
36+
"""HTTP or API-level error from BCB."""
37+
38+
def __init__(self, message: str, status_code: int): # ← Required, not Optional
39+
super().__init__(message)
40+
self.status_code = status_code
41+
```
42+
43+
#### B) Adicionar exceções especializadas para HTTP status codes
44+
```python
45+
class BCBAPINotFoundError(BCBAPIError):
46+
"""Raised when API returns 404 Not Found."""
47+
pass
48+
49+
class BCBRateLimitError(BCBAPIError):
50+
"""Raised when API returns 429 Too Many Requests."""
51+
pass
52+
53+
class BCBAPIServerError(BCBAPIError):
54+
"""Raised when API returns 5xx Server Error."""
55+
pass
56+
```
57+
58+
---
59+
60+
## 2️⃣ Mudanças em `bcb/currency.py`
61+
62+
### Problema 1: `warnings.warn()` em L183
63+
64+
**Localização:** `_fetch_symbol_response()` (L165-185)
65+
66+
**Código atual:**
67+
```python
68+
def _fetch_symbol_response(
69+
symbol: str, start_date: DateInput, end_date: DateInput
70+
) -> Optional["httpx.Response"]:
71+
try:
72+
cid = _get_currency_id(symbol)
73+
except CurrencyNotFoundError:
74+
return None
75+
url = _currency_url(cid, start_date, end_date)
76+
res = _CLIENT.get(url)
77+
if res.headers["Content-Type"].startswith("text/html"):
78+
# BCB returned HTML error page (e.g., no data for date range)
79+
doc = html.parse(BytesIO(res.content)).getroot()
80+
xpath = "//div[@class='msgErro']"
81+
elm = doc.xpath(xpath)[0]
82+
x = elm.text
83+
x = re.sub(r"^\W+", "", x)
84+
x = re.sub(r"\W+$", "", x)
85+
msg = f"BCB API returned error: {x} - {symbol}"
86+
warnings.warn(msg) # ← Problem: Silent warning, caller might miss
87+
return None # ← Returns None instead of raising
88+
return res
89+
```
90+
91+
**Mudança:**
92+
- Remover `warnings.warn()` (L183)
93+
- Remover `return None` (L184)
94+
- Levantar `BCBAPIError` em vez:
95+
96+
```python
97+
def _fetch_symbol_response(
98+
symbol: str, start_date: DateInput, end_date: DateInput
99+
) -> "httpx.Response": # ← No longer Optional
100+
try:
101+
cid = _get_currency_id(symbol)
102+
except CurrencyNotFoundError:
103+
raise # ← Propagate immediately (fail-fast)
104+
url = _currency_url(cid, start_date, end_date)
105+
res = _CLIENT.get(url)
106+
if res.headers["Content-Type"].startswith("text/html"):
107+
# BCB returned HTML error page
108+
doc = html.parse(BytesIO(res.content)).getroot()
109+
xpath = "//div[@class='msgErro']"
110+
elm = doc.xpath(xpath)[0]
111+
x = elm.text
112+
x = re.sub(r"^\W+", "", x)
113+
x = re.sub(r"\W+$", "", x)
114+
msg = f"BCB API returned error: {x} - {symbol}"
115+
raise BCBAPIError(msg, status_code=400) # ← Raise instead of warn
116+
return res
117+
```
118+
119+
### Problema 2: Função retorna `None` para "não encontrado"
120+
121+
**Localização:** `_get_symbol()` (L188-193)
122+
123+
**Código atual:**
124+
```python
125+
def _get_symbol(
126+
symbol: str, start_date: DateInput, end_date: DateInput
127+
) -> Optional[pd.DataFrame]:
128+
res = _fetch_symbol_response(symbol, start_date, end_date)
129+
if res is None:
130+
return None # ← Silently skips symbol
131+
# ... rest of parsing
132+
```
133+
134+
**Mudança:**
135+
Depois que `_fetch_symbol_response` levantar exceções, não há mais `None`:
136+
137+
```python
138+
def _get_symbol(
139+
symbol: str, start_date: DateInput, end_date: DateInput
140+
) -> pd.DataFrame: # ← No longer Optional
141+
res = _fetch_symbol_response(symbol, start_date, end_date)
142+
# res is guaranteed to be a Response (or exception raised)
143+
# ... rest of parsing
144+
```
145+
146+
### Problema 3: `get()` silenciosamente descarta símbolos inválidos
147+
148+
**Localização:** `get()` (linha ~235-280)
149+
150+
**Código atual:**
151+
```python
152+
def get(
153+
symbols: Union[str, List[str]],
154+
start: DateInput,
155+
end: DateInput,
156+
side: str = "ask",
157+
groupby: str = "symbol",
158+
output: str = "dataframe",
159+
) -> Union[pd.DataFrame, str, Dict[str, str]]:
160+
# ... code ...
161+
for symbol in symbols:
162+
df = _get_symbol(symbol, start_date, end_date)
163+
if df is None:
164+
continue # ← Silently skip invalid symbol
165+
# ... accumulate results
166+
if not results:
167+
raise CurrencyNotFoundError(...) # ← Only raise if ALL failed
168+
```
169+
170+
**Problema:** `get(["USD", "BOGUS"])` silenciosamente descarta `BOGUS` sem aviso.
171+
172+
**Mudança:** Depois que `_get_symbol` levantar exceções, `BOGUS` vai levantar imediatamente:
173+
174+
```python
175+
def get(
176+
symbols: Union[str, List[str]],
177+
start: DateInput,
178+
end: DateInput,
179+
side: str = "ask",
180+
groupby: str = "symbol",
181+
output: str = "dataframe",
182+
) -> Union[pd.DataFrame, str, Dict[str, str]]:
183+
# ... code ...
184+
for symbol in symbols:
185+
df = _get_symbol(symbol, start_date, end_date) # ← Raises on error
186+
# ... accumulate results
187+
# results will always be populated or exception raised
188+
```
189+
190+
### Tipo de retorno não-Optional
191+
192+
Remover `Optional` de:
193+
- L168: `_fetch_symbol_response()``"httpx.Response"` (não `Optional["httpx.Response"]`)
194+
- L190: `_get_symbol()``pd.DataFrame` (não `Optional[pd.DataFrame]`)
195+
- L213: `_get_symbol_text()``str` (não `Optional[str]`)
196+
197+
---
198+
199+
## 3️⃣ Mudanças em `bcb/sgs/__init__.py`
200+
201+
### Problema: Bare `except Exception` em L253
202+
203+
**Localização:** `get_json()` (L250-254)
204+
205+
**Código atual:**
206+
```python
207+
if res.status_code != 200:
208+
try:
209+
res_json = json.loads(res.text)
210+
except Exception: # ← TOO BROAD
211+
res_json = {}
212+
if "error" in res_json:
213+
raise SGSError(...)
214+
elif "erro" in res_json:
215+
raise SGSError(...)
216+
raise SGSError(...)
217+
```
218+
219+
**Problema:** `except Exception` pega tudo, incluindo `AttributeError`, `TypeError`, etc.
220+
221+
**Mudança:**
222+
```python
223+
if res.status_code != 200:
224+
try:
225+
res_json = json.loads(res.text)
226+
except json.JSONDecodeError: # ← Specific exception
227+
res_json = {}
228+
if "error" in res_json:
229+
raise SGSError(...)
230+
elif "erro" in res_json:
231+
raise SGSError(...)
232+
raise SGSError(...)
233+
```
234+
235+
### Adicionar detecção de 429 em `get_json()`
236+
237+
```python
238+
if res.status_code == 429:
239+
raise BCBRateLimitError(
240+
"BCB API rate limit exceeded. Please try again later.",
241+
status_code=429
242+
)
243+
if res.status_code != 200:
244+
# ... existing error handling
245+
```
246+
247+
---
248+
249+
## 4️⃣ Mudanças em `bcb/currency.py` — Detecção de 429
250+
251+
**Localização:** `_currency_id_list()` (L49-64)
252+
253+
**Código atual:**
254+
```python
255+
def _currency_id_list() -> pd.DataFrame:
256+
# ...
257+
res = _CLIENT.get(url1)
258+
if res.status_code != 200:
259+
msg = f"BCB API Request error, status code = {res.status_code}"
260+
raise BCBAPIError(msg, res.status_code)
261+
```
262+
263+
**Mudança:** Adicionar detecção de 429:
264+
265+
```python
266+
def _currency_id_list() -> pd.DataFrame:
267+
# ...
268+
res = _CLIENT.get(url1)
269+
if res.status_code == 429:
270+
raise BCBRateLimitError(
271+
"BCB API rate limit exceeded",
272+
status_code=429
273+
)
274+
if res.status_code != 200:
275+
msg = f"BCB API Request error, status code = {res.status_code}"
276+
raise BCBAPIError(msg, res.status_code)
277+
```
278+
279+
Também em `_fetch_symbol_response()` (após chamada a `_CLIENT.get(url)`).
280+
281+
---
282+
283+
## 5️⃣ Import necessário
284+
285+
Adicionar a `bcb/currency.py`:
286+
287+
```python
288+
from bcb.exceptions import (
289+
BCBAPIError,
290+
BCBRateLimitError,
291+
CurrencyNotFoundError,
292+
)
293+
```
294+
295+
---
296+
297+
## 📋 Checklist Phase 2
298+
299+
**Arquivo: `bcb/exceptions.py`**
300+
- [ ] Tornar `BCBAPIError.status_code` obrigatório (remove `Optional`)
301+
- [ ] Adicionar `BCBAPINotFoundError(BCBAPIError)` (para 404)
302+
- [ ] Adicionar `BCBRateLimitError(BCBAPIError)` (para 429)
303+
- [ ] Adicionar `BCBAPIServerError(BCBAPIError)` (para 5xx) — optional
304+
305+
**Arquivo: `bcb/currency.py`**
306+
- [ ] Remover `warnings.warn()` em `_fetch_symbol_response()`
307+
- [ ] Mudar `_fetch_symbol_response()` para levantar em vez de retornar `None`
308+
- [ ] Mudar tipo de retorno para `"httpx.Response"` (não `Optional`)
309+
- [ ] Adicionar detecção de 429 em `_currency_id_list()`
310+
- [ ] Adicionar detecção de 429 em `_fetch_symbol_response()`
311+
- [ ] Remover `Optional` de `_get_symbol()` e `_get_symbol_text()`
312+
- [ ] Remover `import warnings` (não mais usado)
313+
- [ ] Atualizar imports de exceções
314+
315+
**Arquivo: `bcb/sgs/__init__.py`**
316+
- [ ] Mudar `except Exception``except json.JSONDecodeError` (L253)
317+
- [ ] Adicionar detecção de 429 em `get_json()`
318+
319+
**Testes:**
320+
- [ ] Rodar `uv run pytest -m "not integration"` — alguns testes vão falhar por mudança de API
321+
- [ ] Verificar `uv run mypy bcb/` — 0 errors
322+
- [ ] Verificar `uv run ruff check bcb/` — all checks passed
323+
- [ ] Verificar `uv run ruff format --check bcb/` — properly formatted
324+
325+
---
326+
327+
## 🔄 Impacto nos testes
328+
329+
Esses testes vão **FALHAR** após Phase 2 (porque a API mudou):
330+
- `test_get_symbol_unknown_currency_returns_none` — agora levanta exceção
331+
- `test_currency_get_unknown_symbol_raises` — nome enganoso, na verdade espera None
332+
- Qualquer teste que chama `get(["BOGUS"])` sem expect exceção
333+
334+
Isso é **ESPERADO** — Phase 7 vai consertar os testes.
335+
336+
---
337+
338+
## ⚠️ Pontos de atenção
339+
340+
1. **Breaking change:** Funções que retornavam `None` agora levantam exceções
341+
2. **Testes vão falhar:** Phase 7 vai atualizar os testes
342+
3. **Retry automático:** O cliente HTTP vai automaticamente retry em 5xx (via tenacity)
343+
4. **429 não tem retry:** Levantará `BCBRateLimitError` imediatamente (correto — não retry em rate limit)
344+
345+
---
346+
347+
## Decisões para você revisar:
348+
349+
1. **BCBAPIServerError para 5xx?** Adicionar ou manter genérico?
350+
- Opção A: Adicionar (mais específico)
351+
- Opção B: Manter genérico com `BCBAPIError(msg, status_code=500)`
352+
353+
2. **Tipo de retorno para `_fetch_symbol_response()`:**
354+
- Opção A: `"httpx.Response"` (nunca retorna None)
355+
- Opção B: Manter como está (mais compatível com testes antigos)
356+
357+
Recomendo Opção A em ambos.

0 commit comments

Comments
 (0)