Fetches the list of currencies supported by the provider. Must raise ProviderError on any failure.Returns:list[dict[str, str]] - List of currency dictionaries with code and name keysExample:
The client parameter allows dependency injection for testing. Always accept it as an optional parameter.
2
Implement the request helper
Create a private _request() method to handle HTTP calls and error conversion:
async def _request(self, endpoint: str, params: dict) -> dict: params['api_key'] = self.api_key # Add auth to params url = f'{self.BASE_URL}/{endpoint}' try: response = await self._client.get(url, params=params) response.raise_for_status() data = response.json() # Check for API-level errors if 'error' in data: message = data.get('error', {}).get('message', 'Unknown error') raise ProviderError(f'YourProvider API error: {message}') return data except httpx.HTTPStatusError as e: raise ProviderError( f'YourProvider HTTP error {e.response.status_code}: {e.response.text[:200]}' ) from e except httpx.RequestError as e: raise ProviderError(f'YourProvider request failed: {e.__class__.__name__}') from e except Exception as e: raise ProviderError(f'YourProvider response parsing error: {str(e)}') from e
Always convert all exceptions to ProviderError. This ensures consistent error handling throughout the application.
3
Implement fetch_rate
Fetch the exchange rate and return it as a Decimal:
async def fetch_rate(self, from_currency: str, to_currency: str) -> Decimal: data = await self._request('latest', { 'base': from_currency, 'symbols': to_currency }) try: # Extract rate from response (adjust based on API format) rate_value = data['rates'][to_currency] # CRITICAL: Convert via str to preserve precision return Decimal(str(rate_value)) except KeyError as e: raise ProviderError(f'Missing rate for {to_currency}') from e
Always convert float values to Decimal via str(): Decimal(str(float_value)). Never use Decimal(float_value) directly.
4
Implement fetch_supported_currencies
Fetch and return the list of supported currencies:
async def fetch_supported_currencies(self) -> list[dict]: data = await self._request('currencies', {}) # Transform API response to standard format return [ {'code': code, 'name': name} for code, name in data['currencies'].items() ]
5
Export the provider
Add your provider to infrastructure/providers/__init__.py:
Parallel fetching - All providers are called simultaneously using asyncio.gather()
Failure tolerance - If 1-2 providers fail, the service averages the remaining responses
Complete failure - If all providers fail, a ProviderError is raised (HTTP 503)
application/services/rate_service.py (simplified)
async def _aggregate_rates(self, from_currency: str, to_currency: str) -> AggregatedRate: tasks = [ self._fetch_with_fallback(provider, from_currency, to_currency) for provider in [self.primary_provider] + self.secondary_providers ] results = await asyncio.gather(*tasks, return_exceptions=True) successful = [r for r in results if isinstance(r, ExchangeRate)] if not successful: raise ProviderError("All providers failed") avg_rate = sum(r.rate for r in successful) / len(successful) return AggregatedRate(rate=avg_rate, sources=[r.source for r in successful])
Problem: Converting floats directly to Decimal loses precision.
# Wrongreturn Decimal(rate_value) # If rate_value is a float# Correctreturn Decimal(str(rate_value))
Not converting all exceptions to ProviderError
Problem: Letting raw HTTP or parsing exceptions bubble up breaks the service’s error handling.Solution: Wrap all exceptions in ProviderError in your _request() method.
Forgetting to inject the client for testing
Problem: Hardcoding httpx.AsyncClient() in __init__ makes testing difficult.Solution: Always accept client as an optional parameter: