Skip to content

Commit 7c7d79e

Browse files
committed
fix: add error handling and fix validation errors
- Add _serialize_model() helper to exclude None values from API payloads - Firefly III API rejects None/null values for many fields - Automatically applies exclude_none=True to all model serialization - Supports exclude_unset=True for update operations - Add _handle_api_error() helper for detailed error logging - Logs HTTP errors (status >= 400) with response body - Includes request URL and payload (for POST/PUT) in logs - Helps debug validation errors and API issues - Apply error handlers to all API methods - All 18 API endpoints now have consistent error logging - Transaction creation/update methods use _serialize_model() - GET/DELETE methods use _handle_api_error() without payload
1 parent 75fb3bb commit 7c7d79e

File tree

1 file changed

+57
-11
lines changed

1 file changed

+57
-11
lines changed

src/lampyrid/clients/firefly.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from typing import Any, Dict, List
23

34
import httpx
@@ -45,6 +46,8 @@
4546
UpdateTransactionRequest,
4647
)
4748

49+
logger = logging.getLogger(__name__)
50+
4851

4952
class FireflyClient:
5053
def __init__(self) -> None:
@@ -59,16 +62,43 @@ def __init__(self) -> None:
5962
timeout=30.0,
6063
)
6164

65+
def _serialize_model(self, model: Any, exclude_unset: bool = False) -> Dict[str, Any]:
66+
"""Serialize a Pydantic model to dict, excluding None values by default.
67+
68+
Firefly III API rejects None values for many fields, so we exclude them.
69+
Use exclude_unset=True for update operations to only send changed fields.
70+
"""
71+
return model.model_dump(mode='json', exclude_none=True, exclude_unset=exclude_unset)
72+
73+
def _handle_api_error(
74+
self, response: httpx.Response, payload: Dict[str, Any] | None = None
75+
) -> None:
76+
"""Log detailed error information for API errors.
77+
78+
Args:
79+
response: The HTTP response object
80+
payload: The request payload that was sent (optional, for POST/PUT requests)
81+
"""
82+
if response.status_code >= 400:
83+
logger.error(
84+
f'Firefly III API error ({response.status_code}): {response.text}',
85+
)
86+
if payload:
87+
logger.error(f'Request payload: {payload}')
88+
logger.error(f'Request URL: {response.request.url}')
89+
6290
async def list_accounts(
6391
self, page: int = 1, type: AccountTypeFilter = AccountTypeFilter.all
6492
) -> AccountArray:
6593
r = await self._client.get('/api/v1/accounts', params={'page': page, 'type': type.value})
94+
self._handle_api_error(r)
6695
r.raise_for_status()
6796
return AccountArray.model_validate(r.json())
6897

6998
async def get_account(self, req: GetAccountRequest) -> Account:
7099
"""Get a single account by ID."""
71100
r = await self._client.get(f'/api/v1/accounts/{req.id}')
101+
self._handle_api_error(r)
72102
r.raise_for_status()
73103
account_single = AccountSingle.model_validate(r.json())
74104
return Account.from_account_read(account_single.data)
@@ -84,7 +114,7 @@ async def search_accounts(self, req: SearchAccountRequest) -> AccountArray:
84114
'page': 1,
85115
},
86116
)
87-
117+
self._handle_api_error(r)
88118
r.raise_for_status()
89119
return AccountArray.model_validate(r.json())
90120

@@ -164,6 +194,7 @@ async def search_transactions(self, req: SearchTransactionsRequest) -> Transacti
164194
}
165195

166196
r = await self._client.get('/api/v1/search/transactions', params=params)
197+
self._handle_api_error(r)
167198
r.raise_for_status()
168199
return TransactionArray.model_validate(r.json())
169200

@@ -179,7 +210,9 @@ async def create_withdrawal(self, withdrawal: CreateWithdrawalRequest) -> Transa
179210
budget_name=withdrawal.budget_name,
180211
)
181212
trx_store = TransactionStore(transactions=[trx])
182-
r = await self._client.post('/api/v1/transactions', json=trx_store.model_dump(mode='json'))
213+
payload = self._serialize_model(trx_store)
214+
r = await self._client.post('/api/v1/transactions', json=payload)
215+
self._handle_api_error(r, payload)
183216
r.raise_for_status()
184217
res = TransactionSingle.model_validate(r.json())
185218
return Transaction.from_transaction_single(res)
@@ -194,7 +227,9 @@ async def create_deposit(self, deposit: CreateDepositRequest) -> Transaction:
194227
destination_id=deposit.destination_id,
195228
)
196229
trx_store = TransactionStore(transactions=[trx])
197-
r = await self._client.post('/api/v1/transactions', json=trx_store.model_dump(mode='json'))
230+
payload = self._serialize_model(trx_store)
231+
r = await self._client.post('/api/v1/transactions', json=payload)
232+
self._handle_api_error(r, payload)
198233
r.raise_for_status()
199234
res = TransactionSingle.model_validate(r.json())
200235
return Transaction.from_transaction_single(res)
@@ -209,7 +244,9 @@ async def create_transfer(self, transfer: CreateTransferRequest) -> Transaction:
209244
destination_id=transfer.destination_id,
210245
)
211246
trx_store = TransactionStore(transactions=[trx])
212-
r = await self._client.post('/api/v1/transactions', json=trx_store.model_dump(mode='json'))
247+
payload = self._serialize_model(trx_store)
248+
r = await self._client.post('/api/v1/transactions', json=payload)
249+
self._handle_api_error(r, payload)
213250
r.raise_for_status()
214251
res = TransactionSingle.model_validate(r.json())
215252
return Transaction.from_transaction_single(res)
@@ -228,9 +265,9 @@ async def create_bulk_transactions(
228265
fire_webhooks=True,
229266
error_if_duplicate_hash=False,
230267
)
231-
r = await self._client.post(
232-
'/api/v1/transactions', json=trx_store.model_dump(mode='json')
233-
)
268+
payload = self._serialize_model(trx_store)
269+
r = await self._client.post('/api/v1/transactions', json=payload)
270+
self._handle_api_error(r, payload)
234271
r.raise_for_status()
235272
res = TransactionSingle.model_validate(r.json())
236273
created_transactions.append(Transaction.from_transaction_single(res))
@@ -263,10 +300,9 @@ async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction
263300
apply_rules=False, fire_webhooks=True, group_title=None, transactions=[trx_split_update]
264301
)
265302

266-
r = await self._client.put(
267-
f'/api/v1/transactions/{req.transaction_id}',
268-
json=trx_update.model_dump(mode='json', exclude_unset=True),
269-
)
303+
payload = self._serialize_model(trx_update, exclude_unset=True)
304+
r = await self._client.put(f'/api/v1/transactions/{req.transaction_id}', json=payload)
305+
self._handle_api_error(r, payload)
270306
r.raise_for_status()
271307
transaction_single = TransactionSingle.model_validate(r.json())
272308
return Transaction.from_transaction_single(transaction_single)
@@ -306,6 +342,7 @@ async def get_transactions(self, req: GetTransactionsRequest) -> TransactionArra
306342
params['type'] = req.transaction_type.value
307343

308344
r = await self._client.get('/api/v1/transactions', params=params)
345+
self._handle_api_error(r)
309346
r.raise_for_status()
310347
return TransactionArray.model_validate(r.json())
311348

@@ -329,25 +366,29 @@ async def get_account_transactions(self, req: GetTransactionsRequest) -> Transac
329366
raise ValueError('account_id must be provided for account transactions retrieval.')
330367

331368
r = await self._client.get(f'/api/v1/accounts/{req.account_id}/transactions', params=params)
369+
self._handle_api_error(r)
332370
r.raise_for_status()
333371
return TransactionArray.model_validate(r.json())
334372

335373
async def get_transaction(self, req: GetTransactionRequest) -> Transaction:
336374
"""Get a single transaction by ID."""
337375
r = await self._client.get(f'/api/v1/transactions/{req.id}')
376+
self._handle_api_error(r)
338377
r.raise_for_status()
339378
transaction_single = TransactionSingle.model_validate(r.json())
340379
return Transaction.from_transaction_single(transaction_single)
341380

342381
async def delete_transaction(self, req: DeleteTransactionRequest) -> bool:
343382
"""Delete a transaction by ID."""
344383
r = await self._client.delete(f'/api/v1/transactions/{req.id}')
384+
self._handle_api_error(r)
345385
r.raise_for_status()
346386
return r.status_code == 204
347387

348388
async def list_budgets(self, req: ListBudgetsRequest) -> BudgetArray:
349389
"""List all budgets."""
350390
r = await self._client.get('/api/v1/budgets')
391+
self._handle_api_error(r)
351392
r.raise_for_status()
352393
budget_array = BudgetArray.model_validate(r.json())
353394
if req.active is not None:
@@ -357,6 +398,7 @@ async def list_budgets(self, req: ListBudgetsRequest) -> BudgetArray:
357398
async def get_budget(self, req: GetBudgetRequest) -> Budget:
358399
"""Get a single budget by ID."""
359400
r = await self._client.get(f'/api/v1/budgets/{req.id}')
401+
self._handle_api_error(r)
360402
r.raise_for_status()
361403
budget_single = BudgetSingle.model_validate(r.json())
362404
return Budget.from_budget_read(budget_single.data)
@@ -372,6 +414,7 @@ async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpen
372414

373415
# Get budget info first
374416
budget_r = await self._client.get(f'/api/v1/budgets/{req.budget_id}')
417+
self._handle_api_error(budget_r)
375418
budget_r.raise_for_status()
376419
budget_single = BudgetSingle.model_validate(budget_r.json())
377420
budget_name = budget_single.data.attributes.name
@@ -380,6 +423,7 @@ async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpen
380423
spending_r = await self._client.get(
381424
f'/api/v1/budgets/{req.budget_id}/limits', params=params
382425
)
426+
self._handle_api_error(spending_r)
383427
spending_r.raise_for_status()
384428
limits_array = BudgetLimitArray.model_validate(spending_r.json())
385429

@@ -415,6 +459,7 @@ async def get_budget_summary(self, req: GetBudgetSummaryRequest) -> BudgetSummar
415459
"""Get summary of all budgets with spending information."""
416460
# Get all budgets
417461
budgets_r = await self._client.get('/api/v1/budgets')
462+
self._handle_api_error(budgets_r)
418463
budgets_r.raise_for_status()
419464
budgets_array = BudgetArray.model_validate(budgets_r.json())
420465

@@ -455,6 +500,7 @@ async def get_available_budget(self, req: GetAvailableBudgetRequest) -> Availabl
455500
params['end'] = req.end_date.strftime('%Y-%m-%d')
456501

457502
r = await self._client.get('/api/v1/available-budgets', params=params)
503+
self._handle_api_error(r)
458504
r.raise_for_status()
459505
available_array = AvailableBudgetArray.model_validate(r.json())
460506

0 commit comments

Comments
 (0)