4. API client layer: resilient communication

In this section you build weather_api_client.py, the API client layer. It's the only place in the project that talks to external HTTP. It wraps every outbound call with the retry-with-backoff helper from errors.py, applies a sensible timeout, and packages success and failure into the same return shape, APIResult, so the layers above never see a raw requests exception.

Raw HTTP failures (timeouts, connection errors, rate limits, server errors, invalid responses) flowing into the API client layer's three habits: retry with backoff, one shape on every path, category as data. The output is a single APIResult carrying parsed data on success or a categorised error_context on failure.
Many failures in, one result out. Raw HTTP exceptions and error responses arrive on one side of this boundary; a single APIResult carrying parsed data or a categorised failure leaves on the other. Nothing downstream needs to wrap calls in try/except or pattern-match on error strings again.

Chapter 7's APIClient hid retry, timeout, and categorisation inside _make_request. This client exposes them as fields on the result, because the orchestrator in §6 branches on the failure category (transient, not_found, user_input, unknown) to decide whether to retry, give up, or surface a user-facing message. (For transport failures the category is set here in §4; the not_found category for empty-but-valid responses lands in §6's orchestrator after §5's processor raises a ValidationError on the empty list, see Test 2 below.) That's the architectural difference: the categoriser is data, not error-type pattern matching.

WeatherAPIClient: retry baked in, one shape per call

The client is a thin shell around retry_request() from errors.py. It adds the OpenWeatherMap base URLs, the API key from configuration, and an APIResult dataclass that carries either the parsed JSON payload or the categorised error context. Save this at the project root as weather_api_client.py:

weather_api_client.py
"""
Weather API Client Layer - Resilient external communication.

Integrates Chapter 9's retry logic with weather-specific configuration.
Provides clean interfaces that hide resilience complexity from upper layers.
"""
from dataclasses import dataclass
from typing import Optional, Dict, Any, Union, List
import os

# Import Chapter 9 utilities
from errors import retry_request

@dataclass
class APIResult:
    """
    Standardized API response wrapper.
    
    Clean interface between API client and processing layers:
    - success: True if data is usable
    - data: Parsed JSON response (or None)
    - error_context: Information for error handling if failed
    """
    success: bool
    data: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]
    error_context: Optional[Dict[str, Any]] = None

class WeatherAPIClient:
    """
    Resilient client for weather and geocoding APIs.
    
    Applies Chapter 9's error handling patterns:
    - Retry with exponential backoff + jitter
    - Systematic error categorization
    """
    
    def __init__(self, api_key: Optional[str] = None):
        """
        Initialize API client with configuration.
        
        Args:
            api_key: OpenWeatherMap API key (from environment if not provided)
        """
        # Convenience for direct instantiation (REPL, ad-hoc scripts); the main
        # app passes api_key explicitly via config.load_config().
        self.api_key = api_key or os.getenv('OPENWEATHER_API_KEY')
        if not self.api_key:
            raise ValueError("API key required: set OPENWEATHER_API_KEY environment variable")
        
        # API endpoints
        self.geocoding_url = "https://api.openweathermap.org/geo/1.0/direct"
        self.weather_url = "https://api.openweathermap.org/data/2.5/weather"
        
        # Retry configuration (from Chapter 9, Section 5)
        self.max_retries = 3
        self.base_timeout = 10.0
        self.base_delay = 1.0
        self.max_delay = 60.0
    
    def fetch_geocoding(self, city_name: str, limit: int = 5) -> APIResult:
        """
        Fetch location coordinates with retry logic.
        
        Applies Chapter 9 patterns:
        - Uses retry_request() for resilient communication
        - Returns standardized APIResult with categorised error_context on failure

        Args:
            city_name: City name to geocode
            limit: Maximum number of results to return
            
        Returns:
            APIResult with geocoding data or error context
        """
        params = {
            'q': city_name,
            'limit': limit,
            'appid': self.api_key
        }
        
        try:
            # Chapter 9's retry_request handles transient failures automatically
            response = retry_request(
                url=self.geocoding_url,
                params=params,
                max_attempts=self.max_retries,
                timeout=self.base_timeout,
                base_delay=self.base_delay,
                max_delay=self.max_delay
            )

            return self._build_result(
                response, api='geocoding', identifier={'city_name': city_name}
            )

        except Exception as e:
            return APIResult(
                success=False,
                data=None,
                error_context={
                    'city_name': city_name,
                    'api': 'geocoding',
                    'category': 'unknown',
                    'detail': 'unexpected',
                    'exception': e,
                }
            )

    def fetch_weather(self, latitude: float, longitude: float) -> APIResult:
        """
        Fetch weather data with retry logic.

        Applies Chapter 9 patterns:
        - Uses retry_request() for resilient communication
        - Returns standardized APIResult
        - error_context['category'] holds Chapter 9's four-category enum
          directly so the orchestrator can branch without parsing strings

        Args:
            latitude: Location latitude
            longitude: Location longitude

        Returns:
            APIResult with weather data or error context
        """
        params = {
            'lat': latitude,
            'lon': longitude,
            'units': 'metric', # Celsius
            'appid': self.api_key
        }

        try:
            response = retry_request(
                url=self.weather_url,
                params=params,
                max_attempts=self.max_retries,
                timeout=self.base_timeout,
                base_delay=self.base_delay,
                max_delay=self.max_delay
            )

            return self._build_result(
                response, api='weather', identifier={'coordinates': (latitude, longitude)}
            )

        except Exception as e:
            return APIResult(
                success=False,
                data=None,
                error_context={
                    'api': 'weather',
                    'coordinates': (latitude, longitude),
                    'category': 'unknown',
                    'detail': 'unexpected',
                    'exception': e,
                }
            )

    def _build_result(self, response, api: str, identifier: dict) -> APIResult:
        """
        Convert a raw response (or None) into an APIResult, attaching the
        appropriate Chapter 9 category directly to error_context.

        Categories surfaced for this client:
          - 'transient' : retries exhausted, 429 rate-limit, 5xx server error
          - 'not_found' : HTTP 404
          - 'unknown' : 401 (config-level), 403, other unexpected 4xx
        """
        if response is None:
            ctx = {**identifier, 'api': api, 'category': 'transient',
                   'detail': 'retries_exhausted'}
            return APIResult(success=False, data=None, error_context=ctx)

        code = response.status_code

        if code == 404:
            ctx = {**identifier, 'api': api, 'status_code': code,
                   'category': 'not_found', 'detail': 'http_404'}
            return APIResult(success=False, data=None, error_context=ctx)

        if code in (429, 503) or 500 <= code < 600:
            ctx = {**identifier, 'api': api, 'status_code': code,
                   'category': 'transient', 'detail': f'http_{code}'}
            return APIResult(success=False, data=None, error_context=ctx)

        if code >= 400:
            ctx = {**identifier, 'api': api, 'status_code': code,
                   'category': 'unknown', 'detail': f'http_{code}'}
            return APIResult(success=False, data=None, error_context=ctx)

        try:
            data = response.json()
        except ValueError as e:
            ctx = {**identifier, 'api': api, 'category': 'unknown',
                   'detail': 'invalid_json', 'exception': e}
            return APIResult(success=False, data=None, error_context=ctx)

        return APIResult(success=True, data=data)

Three habits are baked into this client and worth naming. Retry-with-backoff is the default, so every transient failure (timeout, 502, 503) gets two automatic attempts before the caller hears about it. Failure has the same shape as success, so upper layers branch on result.success instead of wrapping every call in try/except. The categorisation lives on the result, so the orchestrator can ask "was this a user-input problem or a transient network blip?" without parsing strings.

Verifying the API client

Before stacking processors on top of this layer, check that it actually behaves the four ways the orchestrator will rely on. Save this at the project root as test_api_client.py and run it with python test_api_client.py:

test_api_client.py
# Test the API client with various scenarios
from weather_api_client import WeatherAPIClient

client = WeatherAPIClient()

print("=== Testing API Client Layer ===\n")

# Test 1: Valid city (success path)
print("Test 1: Valid city")
result = client.fetch_geocoding("London")
print(f"Success: {result.success}")
if result.success:
    print(f"Data type: {type(result.data)}")
    print(f"Results found: {len(result.data) if isinstance(result.data, list) else 'N/A'}")
print()

# Test 2: Unfindable city -- OWM geocoding returns 200 + [] for a no-match
# query. At this layer, that's transport success: the search succeeded and
# matched zero rows. The "no match" semantic is §5's job (the processor).
print("Test 2: Unfindable city")
result = client.fetch_geocoding("Xqzthlptv99999")
print(f"Success: {result.success}")
print(f"Results: {len(result.data) if isinstance(result.data, list) else 'N/A'}")
print()

# Test 3: Invalid API key (HTTP 401 -> unknown category)
print("Test 3: Invalid API key")
bad_client = WeatherAPIClient(api_key="invalid_key_123")
result = bad_client.fetch_geocoding("Paris")
print(f"Success: {result.success}")
if not result.success:
    print(f"Category: {result.error_context.get('category')}")
print()

# Test 4: Valid weather fetch
print("Test 4: Valid weather fetch")
result = client.fetch_weather(51.5074, -0.1278) # London coordinates
print(f"Success: {result.success}")
if result.success:
    print(f"Data type: {type(result.data)}")
    print(f"Has 'main' section: {'main' in result.data}")
print()
Terminal
=== Testing API Client Layer ===

Test 1: Valid city
Success: True
Data type: 
Results found: 5

Test 2: Unfindable city
Success: True
Results: 0

Test 3: Invalid API key
Success: False
Category: unknown

Test 4: Valid weather fetch
Success: True
Data type: 
Has 'main' section: True

All four cases land where they should. The unfindable-city call returns success=True with an empty results list, the bad API key surfaces as unknown (config-level failure, not a user mistake), and the two valid calls return parsed JSON.

The empty-array response is worth pausing on. At this layer, OWM's geocoding endpoint treats "I searched and found zero matches" as a successful query, HTTP 200 with [], not a missing resource. The not_found category the orchestrator eventually surfaces lives one layer up: §5's GeocodingProcessor raises ValidationError("No locations found...") on the empty list, and §6's orchestrator catches that and packages it. Pushing the judgement down into the API client would mean teaching the transport layer what a "valid" geocoding response looks like, which is exactly the kind of endpoint-specific knowledge §4's lead promised this layer wouldn't carry.

If your output diverges, two failure modes are likely. If Tests 1, 3, and 4 all surface Category: unknown (only Test 3 should), the real key in OPENWEATHER_API_KEY is wrong or expired. If Tests 1 and 4 come back with Category: transient and detail: retries_exhausted, the network is dropping the request before retries finish: check your connection and the timeout value.

The orchestrator can branch on result.error_context['category'] directly instead of pattern-matching on error strings; that's the architectural difference §4's lead promised. With the API client returning predictable APIResult objects on every code path, §5 can build the processing layer that turns those raw payloads into validated, normalised dataclasses the rest of the app can trust.