5. Coordinating the two APIs

The two layers both return either real data or None. The coordination question is what happens between them, and the obvious answer, "call both and hope," breaks the first time geocoding fails. This page starts with that broken version, then adds the validation gates that stop bad data before it reaches the next API.

The naive approach and why it breaks

The tempting implementation is to call both APIs in sequence and trust they'll succeed. It works every time you test with "Dublin." It breaks the first time someone types "Dublim."

Save this as naive_coordination.py:

naive_coordination.py
from geocode_client import GeocodingClient
from weather_client import WeatherClient

def get_weather_simple(city_name):
    """Naive approach. Works until it doesn't."""
    geo = GeocodingClient()
    wx = WeatherClient()

    lat, lon, location = geo.find_location(city_name)
    # No check: blindly pass whatever came back to the weather layer.
    weather = wx.get_weather_data(lat, lon)

    print(f"Weather for {location}:")
    print(f"Temperature: {weather['current']['temperature_2m']}°C")

if __name__ == "__main__":
    get_weather_simple("InvalidCity123")

Run it:

Terminal
$ python naive_coordination.py
Looking up coordinates for 'InvalidCity123'...
No locations found for 'InvalidCity123'
Try checking the spelling or using a more specific name.
Fetching 5-day weather forecast...
Weather request failed: Invalid JSON in response
Weather for None:
Traceback (most recent call last):
  File "naive_coordination.py", line 19, in <module>
    get_weather_simple("InvalidCity123")
  File "naive_coordination.py", line 15, in get_weather_simple
    print(f"Temperature: {weather['current']['temperature_2m']}°C")
                          ~~~~~~~^^^^^^^^^^^
TypeError: 'NoneType' object is not subscriptable

When geocoding returns (None, None, None), the weather layer receives those Nones, fails, and returns None. The naive code then tries to index that missing weather data as if it were a dictionary. Users see a Python traceback instead of "we couldn't find that city." The problem compounds with every additional API. Two layers, two potential None sources, two different places the cascade can crash.

The fail-fast pattern

The fix is a single check after each layer. If something came back as None, stop and report rather than continuing on with invalid inputs.

Save this as integrate_weather.py:

integrate_weather.py
from geocode_client import GeocodingClient
from weather_client import WeatherClient

def get_weather_for_city(city_name):
    """
    Coordinate geocoding and weather lookup with validation gates.

    Returns (weather_data, location_name) on success,
    or (None, None) on any failure.
    """
    geo = GeocodingClient()
    wx = WeatherClient()

    # Step 1: geocoding.
    lat, lon, location_name = geo.find_location(city_name)
    if lat is None:
        print("Cannot get weather without valid location coordinates")
        return None, None

    # Step 2: weather.
    weather_data = wx.get_weather_data(lat, lon)
    if weather_data is None:
        print("Cannot display weather without valid data")
        return None, None

    return weather_data, location_name

if __name__ == "__main__":
    from weather_codes import interpret_weather_code

    for city in ["Dublin", "Tokyo", "InvalidCity123"]:
        print(f"\n{'=' * 60}")
        print(f"Looking up weather for: {city}")
        print("=" * 60)

        weather, location = get_weather_for_city(city)
        if weather is not None:
            current = weather["current"]
            temp = current["temperature_2m"]
            conditions = interpret_weather_code(current["weather_code"])
            print(f"\nSuccess: {location}")
            print(f"  Current: {temp}°C, {conditions}")
        else:
            print("\nWeather lookup failed")

Run it:

Terminal
$ python integrate_weather.py

============================================================
Looking up weather for: Dublin
============================================================
Looking up coordinates for 'Dublin'...
Found: Dublin, Leinster, Ireland
Fetching 5-day weather forecast...
Weather data retrieved successfully

Success: Dublin, Leinster, Ireland
  Current: 8.3°C, Overcast

============================================================
Looking up weather for: Tokyo
============================================================
Looking up coordinates for 'Tokyo'...
Found: Tokyo, Tokyo, Japan
Fetching 5-day weather forecast...
Weather data retrieved successfully

Success: Tokyo, Tokyo, Japan
  Current: 7.2°C, Clear sky

============================================================
Looking up weather for: InvalidCity123
============================================================
Looking up coordinates for 'InvalidCity123'...
No locations found for 'InvalidCity123'
Try checking the spelling or using a more specific name.
Cannot get weather without valid location coordinates

Weather lookup failed

Two real checks. Both live in the same function, both stop the pipeline immediately, both return the same (None, None) shape as the caller-facing contract. No exceptions cross module boundaries; every failure gets classified and surfaced where the user will see it, not three levels deep in someone else's module.

If you see a Python traceback rather than the friendly "Cannot get weather..." message, one of the layers raised instead of returning None. The contract this chapter established is that every flavour of failure inside find_location and get_weather_data collapses to a None return; a leaked exception means a path inside _make_request isn't being caught, which is exactly the kind of bug the APIClient from Chapter 7 is designed to absorb.

How data flows through the gates

The integration function manages data transformation between layers. Geocoding output becomes weather input. Weather output becomes display input. Each transformation includes validation.

data_flow.txt
# Step 1: User Input → Geocoding Layer
city_name: "Dublin"  (string)
    ↓
geocode_location(city_name)
    ↓
(53.33306, -6.24889, "Dublin, Leinster, Ireland")  (lat, lon, name)

# Validation checkpoint
if lat is None:
    return None, None  # Stop here

# Step 2: Coordinates → Weather Layer  
lat: 53.33306  (float)
lon: -6.24889  (float)
    ↓
get_weather_data(lat, lon)
    ↓
{current: {...}, daily: {...}, ...}  (dictionary)

# Validation checkpoint
if weather_data is None:
    return None, None  # Stop here

# Step 3: Weather Data → Display Layer
weather_data: {current: {...}}  (dictionary)
location_name: "Dublin, Leinster, Ireland"  (string)
    ↓
return (weather_data, location_name)  # Success

Each arrow is a transformation. Every transformation is preceded by a check that the previous step's output is usable. Data flows forward only when validation passes. If either check fires, the function returns, and nothing downstream runs.

Distinguishing failure types

The version above treats every failure the same way. For a CLI where the user retypes the city, that's fine. For a web endpoint that needs to return a specific HTTP status code, or a dashboard that should retry on transient failures but not on typos, distinguishing failure types is worth the extra structure. Chapter 9 builds that categorizer properly, with labels for user-input mistakes, transient failures, not-found responses, and unknown errors. For the rest of this chapter, the dashboard sticks with the simpler None-or-data return shape so the coordination pattern stays visible.

The integration function is interface-agnostic

get_weather_for_city doesn't print anything user-facing, doesn't assume a terminal, doesn't know whether the caller is a CLI, a web endpoint, or a GUI. That's the point. The same coordination logic slots under every presentation layer:

presentation_layers.py
from geocode_client import GeocodingClient
from weather_client import WeatherClient

def get_weather_for_city(city_name):
    """Core integration logic. No display code."""
    geo = GeocodingClient()
    wx = WeatherClient()

    lat, lon, location_name = geo.find_location(city_name)
    if lat is None:
        return None, None

    weather_data = wx.get_weather_data(lat, lon)
    if weather_data is None:
        return None, None

    return weather_data, location_name

def display_terminal(city_name):
    """Terminal adapter. Prints a one-line summary."""
    weather, location = get_weather_for_city(city_name)
    if weather is None:
        print("Weather unavailable")
        return
    temp = weather["current"]["temperature_2m"]
    print(f"Weather for {location}: {temp}°C")

def get_json_response(city_name):
    """HTTP-endpoint adapter. Returns a JSON-serialisable dict."""
    weather, location = get_weather_for_city(city_name)
    if weather is None:
        return {"status": "error", "message": "Weather unavailable"}
    return {
        "status": "success",
        "location": location,
        "temperature": weather["current"]["temperature_2m"],
    }

if __name__ == "__main__":
    display_terminal("Dublin")
    print(get_json_response("Tokyo"))

Two concrete adapters, both runnable. The same shape works for any other front end: a desktop GUI would fill its widgets from the tuple, a Slack bot would format the same two fields into a message block, a weather-alerting script would check a threshold and ignore the display call entirely. The integration function doesn't change. Only what sits above it does, which is the whole point of keeping the coordination logic separate from presentation.

The next page folds integration and display into a single WeatherDashboard class with formatting helpers and an interactive loop. That coupling is a deliberate convenience for a CLI app at this scale; for any consumer that isn't a terminal (web endpoint, GUI, alerting script), the separation argued for above is what you'd carry forward instead.