3. Building the geocoding layer
The geocoding layer turns "Dublin" into (53.33306, -6.24889). Everything downstream depends on it, so it's worth getting right before the weather API ever enters the picture. The APIClient from Chapter 7 does most of the heavy lifting; what this page adds is the geocoding-specific endpoint, the response shape, and a clean interface contract that collapses every failure mode into a single if lat is None: check for the caller.
You'll build it in three phases. Phase 1 is a minimal requests.get call just to prove the endpoint works and show what the response looks like. Phase 2 adds the optional parameters the docs call out. Phase 3 is where it gets real: error handling, input validation, and the APIClient subclass you met in Chapter 7 carrying the retry and status-code logic so this layer doesn't re-implement any of it.
Open-Meteo is free and needs no authentication, so you won't see an API key appear in this chapter. The APIClient you built in Chapter 7 carries credential validation that becomes a no-op against keyless APIs; if you skipped Chapter 7, the minimal version on this page handles api_key_env_var=None directly without that extra machinery. Either way, the subclass you build in Phase 3 still gets retry, timeout handling, and 429 backoff without re-implementing them.
Phase 1: prove the endpoint works
Start with the absolute minimum: endpoint, the one required parameter, and a print of whatever comes back. If you created the implementation notes on the previous page, keep geocoding_spec.txt open beside this file; the endpoint, parameters, and response paths below come straight from it.
Save this as geocode_basic.py at the project root:
import requests
# From documentation analysis
geocoding_url = "https://geocoding-api.open-meteo.com/v1/search"
# Minimum required parameter
city_name = "Dublin"
params = {
"name": city_name
}
# Make the request
response = requests.get(geocoding_url, params=params, timeout=10)
print(f"Status code: {response.status_code}")
# Parse JSON response
data = response.json()
print(f"Response type: {type(data)}")
print(f"Top-level keys: {list(data.keys())}")
# Examine results structure
if "results" in data and data["results"]:
first_result = data["results"][0]
print(f"First result keys: {list(first_result.keys())}")
print(f"\nName: {first_result['name']}")
print(f"Latitude: {first_result['latitude']}")
print(f"Longitude: {first_result['longitude']}")
Run it from the project root:
$ python geocode_basic.py
Status code: 200
Response type: <class 'dict'>
Top-level keys: ['results', 'generationtime_ms']
First result keys: ['id', 'name', 'latitude', 'longitude', 'elevation', 'feature_code', 'country_code', 'admin1_id', 'timezone', 'population', 'country', 'admin1']
Name: Dublin
Latitude: 53.33306
Longitude: -6.24889
Four things are now known. The endpoint works (200). The response shape matches the docs: a results array of location dictionaries. The fields you need (latitude, longitude, name) are all present. And context fields like country and admin1 are there if you want them for a formatted display name.
If you see a ConnectionError or Timeout instead of "Status code: 200", the network is the problem (corporate proxy, DNS, or Open-Meteo is unreachable). If you got a 200 but the results key is missing or the first-result fields are absent, the API's response shape changed since this chapter was written -- check the docs and adjust before continuing.
Phase 2: pin the optional parameters
The minimal request works, but it leans on every Open-Meteo default. Add the optional parameters the docs call out: cap the result count, force English, pin the format. Same endpoint, more predictable behavior.
Save this as geocode_enhanced.py:
import requests
geocoding_url = "https://geocoding-api.open-meteo.com/v1/search"
city_name = "London"
# Enhanced parameters from documentation analysis
params = {
"name": city_name,
"count": 5, # Limit to 5 results instead of default 10
"language": "en", # Explicit English responses
"format": "json" # Explicit format specification
}
print(f"Searching for '{city_name}' with enhanced parameters...")
response = requests.get(geocoding_url, params=params, timeout=10)
response.raise_for_status() # Raises exception for HTTP errors
data = response.json()
print(f"Found {len(data['results'])} location(s):\n")
# Display all matches to understand ambiguity
for i, result in enumerate(data["results"], 1):
name = result.get("name", "Unknown")
country = result.get("country", "Unknown")
admin1 = result.get("admin1", "")
lat = result.get("latitude")
lon = result.get("longitude")
location = f"{name}, {admin1}, {country}" if admin1 else f"{name}, {country}"
print(f"{i}. {location}")
print(f" Coordinates: ({lat}, {lon})\n")
Run it:
$ python geocode_enhanced.py
Searching for 'London' with enhanced parameters...
Found 5 location(s):
1. London, England, United Kingdom
Coordinates: (51.50853, -0.12574)
2. London, Ontario, Canada
Coordinates: (42.98339, -81.23304)
3. London, Kentucky, United States
Coordinates: (37.12898, -84.08326)
4. London, Ohio, United States
Coordinates: (39.88645, -83.44825)
5. London, Arkansas, United States
Coordinates: (35.32897, -93.25296)
Common city names have multiple matches. For a weather dashboard, using the first result is fine since the API ranks by relevance; an interactive tool might want to show all five and let the user pick. Either way, the behavior is now predictable rather than depending on whatever Open-Meteo's defaults happen to be today. If the script raises a requests.HTTPError instead of printing matches, that's raise_for_status() doing its job -- Open-Meteo returned a non-200, almost always a 4xx if a parameter is wrong or a 5xx if the service is having a moment.
Phase 3: production geocoding on the Chapter 7 spine
Working requests are only half the solution. Production code has to handle timeouts, rate limits, network blips, empty results, and malformed responses. Rather than rebuild that scaffolding here, subclass the APIClient you wrote in Chapter 7 and let it carry the retry loop, the 429 backoff, the timeout, and the HTTP error handling. This layer only needs to know geocoding-specific things: the endpoint, the parameter shape, the minimum-length rule, and how to pull the right fields out of the response.
Save this as geocode_client.py alongside your Chapter 7 auth_module.py (which exports APIClient):
If you skipped Chapter 7: a minimal auth_module.py that supports keyless APIs
Chapter 7's APIClient handles both keyed and keyless services (the keyless branch is what this chapter exercises). If you completed Chapter 7, you already have auth_module.py on disk and can skip this block. If you didn't, the snippet below is a self-contained version of the same class -- enough to make the rest of this chapter run. The full Chapter 7 walk-through covers credential validation, 401/403 handling, and the design reasoning behind each piece.
"""
Minimal APIClient for Chapter 8.
The full Chapter 7 version adds credential validation, 401/403 handling,
and authenticated-request mechanics. This stripped-down version supports
keyless APIs like Open-Meteo and is enough to make Chapter 8 run.
"""
import os
import time
from typing import Any, Dict, Optional, Tuple
import requests
class APIClient:
"""Base class for HTTP clients with retry, timeout, and error handling."""
def __init__(self, api_key_env_var: Optional[str], base_url: str):
self.base_url = base_url
if api_key_env_var is None:
self.api_key = None
else:
self.api_key = os.getenv(api_key_env_var)
if self.api_key is None:
raise ValueError(
f"API key not found. Set {api_key_env_var} environment variable."
)
def _make_request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
max_retries: int = 3,
) -> Tuple[bool, Optional[dict], str]:
"""Make a GET request with retry and error classification.
Returns (success, data, message).
"""
url = f"{self.base_url}{endpoint}"
for attempt in range(max_retries):
try:
response = requests.get(url, params=params, timeout=10)
if response.ok:
try:
return (True, response.json(), "Success")
except ValueError:
return (False, None, "Invalid JSON in response")
if response.status_code == 429:
if attempt < max_retries - 1:
retry_after = response.headers.get("Retry-After")
wait = int(retry_after) if retry_after else 2 ** attempt
print(f"Rate limited. Waiting {wait}s...")
time.sleep(wait)
continue
return (False, None, "Rate limit exceeded - max retries reached")
if 500 <= response.status_code < 600:
if attempt < max_retries - 1:
wait = 2 ** attempt
print(
f"Server error {response.status_code}. "
f"Retrying in {wait}s..."
)
time.sleep(wait)
continue
return (
False,
None,
f"Server error {response.status_code} - max retries reached",
)
return (False, None, f"Request failed: {response.status_code}")
except requests.exceptions.Timeout:
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
continue
return (False, None, "Request timed out")
except requests.exceptions.RequestException as exc:
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
continue
return (False, None, f"Request failed: {exc}")
return (False, None, "Max retries exceeded")
from auth_module import APIClient
class GeocodingClient(APIClient):
"""Open-Meteo geocoding layer. No API key required."""
def __init__(self):
super().__init__(
api_key_env_var=None, # Open-Meteo is key-less
base_url="https://geocoding-api.open-meteo.com/v1",
)
def find_location(self, city_name):
"""
Convert a city name to coordinates.
Returns (latitude, longitude, formatted_name) on success,
or (None, None, None) on any failure.
"""
if not city_name or len(city_name.strip()) < 2:
print("Error: city name must be at least 2 characters")
return None, None, None
city_name = city_name.strip()
print(f"Looking up coordinates for '{city_name}'...")
success, data, message = self._make_request(
"/search",
params={
"name": city_name,
"count": 5,
"language": "en",
"format": "json",
},
)
if not success:
print(f"Geocoding request failed: {message}")
return None, None, None
results = data.get("results") or []
if not results:
print(f"No locations found for '{city_name}'")
print("Try checking the spelling or using a more specific name.")
return None, None, None
best = results[0]
latitude = best["latitude"]
longitude = best["longitude"]
parts = [best.get("name", "Unknown")]
if best.get("admin1"):
parts.append(best["admin1"])
if best.get("country"):
parts.append(best["country"])
full_name = ", ".join(parts)
print(f"Found: {full_name}")
return latitude, longitude, full_name
if __name__ == "__main__":
client = GeocodingClient()
test_cases = ["Dublin", "Tokyo", "InvalidCity123", "a"]
for city in test_cases:
lat, lon, name = client.find_location(city)
if lat is not None:
print(f"Success: {name} at ({lat}, {lon})")
else:
print("Lookup failed")
print("-" * 50)
The subclass is short because almost nothing is geocoding-specific. The retry loop, the 429 backoff, the timeout handling, and the HTTP error classification all live inside APIClient._make_request, imported from Chapter 7. This file owns the endpoint, the parameter shape, the minimum-length rule, and the three-tuple return contract. When Chapter 16 brings you to Spotify, you'll write a similar subclass against a different base URL and a set of OAuth headers; the shape will be the same.
Run it:
$ python geocode_client.py
Looking up coordinates for 'Dublin'...
Found: Dublin, Leinster, Ireland
Success: Dublin, Leinster, Ireland at (53.33306, -6.24889)
--------------------------------------------------
Looking up coordinates for 'Tokyo'...
Found: Tokyo, Tokyo, Japan
Success: Tokyo, Tokyo, Japan at (35.6895, 139.69171)
--------------------------------------------------
Looking up coordinates for 'InvalidCity123'...
No locations found for 'InvalidCity123'
Try checking the spelling or using a more specific name.
Lookup failed
--------------------------------------------------
Error: city name must be at least 2 characters
Lookup failed
--------------------------------------------------
Four inputs, four paths through the code. Dublin and Tokyo exercise the happy path. "InvalidCity123" gets through to the API but comes back with an empty results array, which is a different failure from a network error and worth its own message. "a" never hits the network at all; input validation catches it upfront and saves a round trip. Four behaviors, one return shape: either you got coordinates or you got (None, None, None). If you see (None, None, None) for Dublin or Tokyo specifically, the APIClient raised internally -- the Geocoding request failed: line printed just above the failure marker tells you why.
Understanding the interface contract
find_location() always returns a three-tuple: (latitude, longitude, formatted_name) on success or (None, None, None) on any failure. That consistency is what makes integration simple on the next page. Calling code only needs one check.
from geocode_client import GeocodingClient
def prepare_weather_request(city_name):
"""Demonstrate using the geocoding interface."""
client = GeocodingClient()
lat, lon, location_name = client.find_location(city_name)
# Single check handles all failure modes.
if lat is None:
print("Cannot fetch weather without valid coordinates")
return None
# Past this point, coordinates are guaranteed valid.
print(f"\nReady to fetch weather for {location_name}")
print(f"Coordinates: ({lat}, {lon})")
# The weather API call will go here on the next page.
return location_name
if __name__ == "__main__":
prepare_weather_request("Paris")
Run it:
$ python use_geocoder.py
Looking up coordinates for 'Paris'...
Found: Paris, Île-de-France, France
Ready to fetch weather for Paris, Île-de-France, France
Coordinates: (48.85341, 2.3488)
The geocoding function handles all the lookup complexity internally, including every flavour of failure. The caller doesn't need to know about network issues, empty results, or response parsing. It checks if lat is None once and moves on. On the next page, the weather layer follows exactly the same shape so the coordination code stays just as simple.