Skip to main content

๐ŸŒ Lesson 10: Working with APIs

Learn to connect your Python programs to the outside world. You'll use the requests library to interact with REST APIs, parse JSON data, handle authentication, manage errors gracefully, and build a mini data dashboard.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an API is and how REST APIs work
  • Use the requests library to make HTTP GET and POST requests
  • Parse and navigate JSON responses
  • Send query parameters, headers, and request bodies
  • Authenticate with API keys and Bearer tokens
  • Handle HTTP errors, timeouts, and connection failures
  • Build a Python script that fetches live data from a public API

Estimated Time: 60 minutes

Prerequisites: Dictionaries, f-strings, error handling (Lesson 5), virtual environments (Lesson 9)

In This Lesson

๐Ÿ”Œ What Is an API?

An API (Application Programming Interface) is a set of rules that lets one piece of software talk to another. When you check the weather on your phone, the app doesn't generate forecasts itself โ€” it sends a request to a weather service's API and displays the response.

Think of it like ordering at a restaurant:

  • You (the client) look at the menu (the API documentation)
  • You give your order (the request) to the waiter (the API)
  • The kitchen (the server) prepares your food and the waiter brings it back (the response)
๐Ÿ–ฅ๏ธ Your Python Program (Client) ๐ŸŒ Remote Server (API Provider) HTTP Request โ†’ โ† JSON Response

๐ŸŒ Web APIs vs. Other APIs

Python has many kinds of APIs โ€” the os module is an API for your operating system, and list.append() is part of the list API. In this lesson, we focus on Web APIs โ€” services you talk to over the internet using HTTP.

๐Ÿ“ก REST & HTTP Basics

Most web APIs follow the REST (Representational State Transfer) pattern. REST APIs use standard HTTP methods to perform operations on resources โ€” things like users, posts, products, or weather data โ€” identified by URLs.

HTTP Methods

Each HTTP method signals a different intent:

Method Purpose Example
GET Retrieve data Fetch a list of users
POST Create new data Submit a new blog post
PUT Replace existing data Update an entire user profile
PATCH Partially update data Change just a user's email
DELETE Remove data Delete a comment

HTTP Status Codes

Every response includes a status code โ€” a three-digit number that tells you what happened:

2xx โœ… Success 200, 201, 204 3xx โ†ช๏ธ Redirect 301, 302, 304 4xx โš ๏ธ Client Error 400, 401, 404 5xx ๐Ÿ”ฅ Server Error 500, 502, 503 Common Status Codes: 200 OK โ€” Request succeeded 201 Created โ€” New resource created 400 Bad Request โ€” Invalid input 401 Unauthorized โ€” Auth required 403 Forbidden โ€” Not allowed 404 Not Found โ€” Resource missing 429 Too Many Requests โ€” Rate limited 500 Internal Server Error

URLs & Endpoints

A REST API organizes resources into predictable URLs called endpoints:

# Base URL
https://api.example.com/v1

# Endpoints (resources)
GET  /users          โ†’ List all users
GET  /users/42       โ†’ Get user with id 42
POST /users          โ†’ Create a new user
PUT  /users/42       โ†’ Replace user 42
DELETE /users/42     โ†’ Delete user 42

๐Ÿ’ก REST Is a Convention

REST isn't a strict protocol โ€” it's a set of design conventions. Not every API follows them perfectly. Always read the API's documentation to understand how it expects you to make requests.

๐Ÿ“ฆ The requests Library

Python's standard library includes urllib for making HTTP requests, but it's verbose and awkward. The third-party requests library is the de facto standard โ€” simple, powerful, and beloved by the Python community.

Installation

Install it in your virtual environment (remember Lesson 9?):

# Create and activate a venv first!
python -m venv .venv
source .venv/bin/activate   # Linux/Mac
# .venv\Scripts\activate    # Windows

# Install requests
pip install requests

Your First Request

import requests

response = requests.get("https://httpbin.org/get")
print(response.status_code)   # 200
print(type(response))         # <class 'requests.models.Response'>

That's it โ€” one line to make an HTTP GET request. The requests.get() function returns a Response object packed with useful attributes:

Attribute / Method Description
response.status_code HTTP status code (200, 404, etc.)
response.text Response body as a string
response.json() Parse JSON body into Python dict/list
response.headers Response headers (dict-like)
response.url The final URL (after redirects)
response.ok True if status is 200โ€“299
response.raise_for_status() Raise HTTPError if status โ‰ฅ 400

๐Ÿ“ฅ Making GET Requests

GET is the most common HTTP method โ€” it retrieves data without changing anything on the server. Let's use a free public API to fetch real data.

Example: Fetching a Random User

import requests

url = "https://randomuser.me/api/"
response = requests.get(url)

if response.ok:
    data = response.json()
    user = data["results"][0]
    name = f"{user['name']['first']} {user['name']['last']}"
    email = user["email"]
    country = user["location"]["country"]
    print(f"Name:    {name}")
    print(f"Email:   {email}")
    print(f"Country: {country}")
else:
    print(f"Request failed: {response.status_code}")

Output (example โ€” results vary):

Name:    Sofia Andersen
Email:   sofia.andersen@example.com
Country: Denmark

Example: Fetching Multiple Items

import requests

# JSONPlaceholder โ€” a free fake REST API for testing
url = "https://jsonplaceholder.typicode.com/posts"
response = requests.get(url)
posts = response.json()  # Returns a list of 100 posts

print(f"Total posts: {len(posts)}")
print(f"First post title: {posts[0]['title']}")

# Print the first 3 posts
for post in posts[:3]:
    print(f"\n[Post {post['id']}] {post['title']}")
    print(f"  {post['body'][:80]}...")

โšก Don't Hammer APIs

Public APIs have rate limits โ€” rules about how many requests you can make per minute/hour. Exceeding them returns a 429 Too Many Requests status. Always read the API docs for rate limit policies, and add delays (time.sleep()) in loops that make repeated requests.

๐Ÿ“‹ Working with JSON

JSON (JavaScript Object Notation) is the standard data format for web APIs. It maps directly to Python data structures:

JSON Type Python Type Example
object {} dict {"name": "Ada"}
array [] list [1, 2, 3]
string str "hello"
number int / float 42, 3.14
true / false True / False true
null None null

Parsing JSON from a Response

import requests

response = requests.get("https://jsonplaceholder.typicode.com/users/1")
user = response.json()   # Parses JSON string โ†’ Python dict

# Navigate the nested structure
print(user["name"])                    # "Leanne Graham"
print(user["address"]["city"])         # "Gwenborough"
print(user["company"]["catchPhrase"])  # "Multi-layered client-server neural-net"

The json Module

Python's built-in json module handles conversion between JSON strings and Python objects. You'll use it when saving API data to files:

import json

# Python dict โ†’ JSON string
data = {"name": "Ada", "languages": ["Python", "Haskell"]}
json_string = json.dumps(data, indent=2)
print(json_string)

# JSON string โ†’ Python dict
parsed = json.loads(json_string)
print(parsed["name"])  # "Ada"

# Save to file
with open("user_data.json", "w") as f:
    json.dump(data, f, indent=2)

# Load from file
with open("user_data.json") as f:
    loaded = json.load(f)
    print(loaded)  # {'name': 'Ada', 'languages': ['Python', 'Haskell']}
graph LR A["๐Ÿ Python Object
(dict, list)"] -->|"json.dumps()"| B["๐Ÿ“ JSON String"] B -->|"json.loads()"| A A -->|"json.dump(file)"| C["๐Ÿ“ JSON File"] C -->|"json.load(file)"| A style A fill:#eff6ff,stroke:#3b82f6,color:#1e293b style B fill:#f0fdf4,stroke:#22c55e,color:#1e293b style C fill:#fefce8,stroke:#f59e0b,color:#1e293b

๐Ÿง  .json() vs json.loads()

Calling response.json() is a shortcut for json.loads(response.text). Both give you the same Python dict/list. Use response.json() when working with API responses โ€” it's cleaner. Use the json module directly when reading/writing files or working with raw JSON strings.

๐Ÿ”ง Query Parameters & Headers

Query Parameters

Many APIs accept query parameters โ€” key-value pairs appended to the URL after a ?. Instead of building the URL string manually, pass a params dict:

import requests

# โŒ Manual URL building โ€” error-prone, no encoding
url = "https://api.example.com/search?q=python+tutorial&limit=5"

# โœ… Use the params argument โ€” clean and auto-encoded
url = "https://api.example.com/search"
params = {
    "q": "python tutorial",
    "limit": 5,
    "sort": "relevance"
}
response = requests.get(url, params=params)
print(response.url)
# https://api.example.com/search?q=python+tutorial&limit=5&sort=relevance

Real Example: Searching the Open Library API

import requests

url = "https://openlibrary.org/search.json"
params = {"q": "dune frank herbert", "limit": 3}
response = requests.get(url, params=params)
data = response.json()

print(f"Found {data['numFound']} results.\n")
for book in data["docs"][:3]:
    title = book.get("title", "Unknown")
    author = ", ".join(book.get("author_name", ["Unknown"]))
    year = book.get("first_publish_year", "N/A")
    print(f"๐Ÿ“– {title}")
    print(f"   by {author} ({year})\n")

Custom Headers

HTTP headers carry metadata about the request โ€” content type, authentication, user-agent, etc. Pass them via the headers argument:

import requests

headers = {
    "Accept": "application/json",
    "User-Agent": "MyPythonApp/1.0"
}
response = requests.get(
    "https://httpbin.org/headers",
    headers=headers
)
print(response.json())

๐Ÿ’ก When to Use Custom Headers

  • Accept โ€” Tell the API what format you want (application/json, text/xml)
  • Authorization โ€” Send API keys or tokens (covered in the Authentication section)
  • Content-Type โ€” Describe the format of data you're sending (application/json)
  • User-Agent โ€” Identify your application (some APIs require it)

๐Ÿ“ค POST Requests & Sending Data

While GET retrieves data, POST sends data to the server to create or process something. Use the json parameter to send JSON data automatically:

import requests

url = "https://jsonplaceholder.typicode.com/posts"
new_post = {
    "title": "Learning APIs with Python",
    "body": "The requests library makes it incredibly easy!",
    "userId": 1
}

response = requests.post(url, json=new_post)
print(response.status_code)  # 201 (Created)
print(response.json())
# {'title': 'Learning APIs with Python', 'body': '...', 'userId': 1, 'id': 101}

๐Ÿ“ json= vs data=

The json= parameter automatically serializes your dict to JSON and sets the Content-Type header to application/json. The older data= parameter sends form-encoded data (application/x-www-form-urlencoded). For modern REST APIs, json= is almost always what you want.

Other HTTP Methods

import requests

base = "https://jsonplaceholder.typicode.com/posts"

# PUT โ€” Replace entire resource
response = requests.put(f"{base}/1", json={
    "id": 1,
    "title": "Updated Title",
    "body": "Completely replaced body.",
    "userId": 1
})
print(f"PUT: {response.status_code}")   # 200

# PATCH โ€” Partial update
response = requests.patch(f"{base}/1", json={
    "title": "Only Changing the Title"
})
print(f"PATCH: {response.status_code}")  # 200

# DELETE โ€” Remove resource
response = requests.delete(f"{base}/1")
print(f"DELETE: {response.status_code}") # 200
graph TD C["๐Ÿ Python Client"] -->|"requests.get()"| GET["๐Ÿ“ฅ GET
Read data"] C -->|"requests.post()"| POST["๐Ÿ“ค POST
Create data"] C -->|"requests.put()"| PUT["๐Ÿ”„ PUT
Replace data"] C -->|"requests.patch()"| PATCH["โœ๏ธ PATCH
Update data"] C -->|"requests.delete()"| DEL["๐Ÿ—‘๏ธ DELETE
Remove data"] GET --> S["๐ŸŒ Server"] POST --> S PUT --> S PATCH --> S DEL --> S style C fill:#eff6ff,stroke:#3b82f6,color:#1e293b style S fill:#f0fdf4,stroke:#22c55e,color:#1e293b style GET fill:#f8fafc,stroke:#22c55e,color:#1e293b style POST fill:#f8fafc,stroke:#6366f1,color:#1e293b style PUT fill:#f8fafc,stroke:#f59e0b,color:#1e293b style PATCH fill:#f8fafc,stroke:#f59e0b,color:#1e293b style DEL fill:#f8fafc,stroke:#ef4444,color:#1e293b

๐Ÿ” Authentication

Many APIs require you to prove who you are before they'll respond. The most common methods are API keys and Bearer tokens.

API Key in Query Parameters

Some APIs expect the key as a query parameter:

import requests

API_KEY = "your_api_key_here"  # Get from the service's dashboard

response = requests.get(
    "https://api.example.com/data",
    params={"api_key": API_KEY}
)

API Key in Headers

Others expect it in a custom header:

import requests

API_KEY = "your_api_key_here"

response = requests.get(
    "https://api.example.com/data",
    headers={"X-API-Key": API_KEY}
)

Bearer Token (OAuth2-style)

Many modern APIs use a Bearer token in the Authorization header:

import requests

TOKEN = "eyJhbGciOiJIUzI1NiIsInR5..."  # Obtained through login/OAuth flow

response = requests.get(
    "https://api.example.com/me",
    headers={"Authorization": f"Bearer {TOKEN}"}
)

๐Ÿ”’ Never Hardcode Secrets!

API keys and tokens should never be committed to Git or hardcoded in scripts. Use environment variables instead:

import os
import requests

API_KEY = os.environ.get("MY_API_KEY")
if not API_KEY:
    raise RuntimeError("Set MY_API_KEY environment variable")

response = requests.get(
    "https://api.example.com/data",
    headers={"Authorization": f"Bearer {API_KEY}"}
)

Set the variable before running your script:

# Linux/Mac
export MY_API_KEY="abc123secret"

# Windows (PowerShell)
$env:MY_API_KEY = "abc123secret"

For projects, use a .env file with the python-dotenv library and add .env to your .gitignore.

sequenceDiagram participant C as ๐Ÿ Client participant A as ๐Ÿ” Auth Server participant API as ๐ŸŒ API Server C->>A: 1. Login / request token A-->>C: 2. Access token ๐ŸŽŸ๏ธ C->>API: 3. Request + Bearer token API-->>C: 4. Protected data โœ… Note over C,API: Token expires โ†’ repeat steps 1โ€“2

๐Ÿ›ก๏ธ Error Handling & Retries

Real-world APIs fail. Servers go down, networks drop, rate limits kick in. Robust code handles all of these gracefully.

Basic Error Handling

import requests

try:
    response = requests.get(
        "https://api.example.com/data",
        timeout=10  # seconds โ€” always set a timeout!
    )
    response.raise_for_status()  # Raises HTTPError for 4xx/5xx
    data = response.json()

except requests.exceptions.Timeout:
    print("โฐ Request timed out โ€” try again later")

except requests.exceptions.ConnectionError:
    print("๐Ÿ”Œ Could not connect โ€” check your network")

except requests.exceptions.HTTPError as e:
    print(f"๐Ÿšซ HTTP error: {e.response.status_code}")
    if e.response.status_code == 404:
        print("   Resource not found")
    elif e.response.status_code == 429:
        print("   Rate limited โ€” slow down!")

except requests.exceptions.RequestException as e:
    print(f"โŒ Request failed: {e}")
RequestException ConnectionError Timeout HTTPError 4xx / 5xx status codes DNS failure, refused, etc. ConnectTimeout, ReadTimeout ๐Ÿ’ก Catch specific exceptions first, RequestException last (catch-all)

Timeouts

Always set a timeout. Without one, your program could hang forever waiting for a server that never responds:

# timeout=10 โ†’ max 10 seconds total
response = requests.get(url, timeout=10)

# Separate connect and read timeouts
response = requests.get(url, timeout=(3.05, 27))
#                                     connect  read

Simple Retry Logic

import time
import requests

def fetch_with_retries(url, max_retries=3, backoff_factor=1):
    """Fetch a URL with exponential backoff on failure."""
    for attempt in range(max_retries):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            return response

        except (requests.exceptions.ConnectionError,
                requests.exceptions.Timeout) as e:
            wait = backoff_factor * (2 ** attempt)  # 1s, 2s, 4s
            print(f"Attempt {attempt + 1} failed: {e}")
            print(f"Retrying in {wait}s...")
            time.sleep(wait)

        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 429:
                wait = backoff_factor * (2 ** attempt)
                print(f"Rate limited. Retrying in {wait}s...")
                time.sleep(wait)
            else:
                raise  # Don't retry other HTTP errors

    raise requests.exceptions.ConnectionError(
        f"Failed after {max_retries} retries"
    )

# Usage
response = fetch_with_retries("https://api.example.com/data")
data = response.json()

โœ… Production Tip: urllib3.util.Retry

For production code, use the requests library's built-in retry adapter instead of writing your own loop:

from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
retries = Retry(total=3, backoff_factor=1,
                status_forcelist=[429, 500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retries))

response = session.get("https://api.example.com/data", timeout=10)

๐Ÿ—๏ธ Mini-Project: Country Info Dashboard

Let's bring it all together. We'll build a command-line dashboard that fetches country data from the free REST Countries API and presents it neatly.

"""
country_dashboard.py โ€” Fetch and display country info from REST Countries API.
"""
import requests
import json


def fetch_country(name):
    """Fetch country data by name. Returns dict or None."""
    url = f"https://restcountries.com/v3.1/name/{name}"
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        countries = response.json()
        return countries[0]  # Take first match
    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 404:
            print(f"โŒ Country '{name}' not found.")
        else:
            print(f"โŒ HTTP error: {e.response.status_code}")
    except requests.exceptions.RequestException as e:
        print(f"โŒ Request failed: {e}")
    return None


def display_country(country):
    """Pretty-print country information."""
    name = country["name"]["common"]
    official = country["name"]["official"]
    capital = ", ".join(country.get("capital", ["N/A"]))
    region = country.get("region", "N/A")
    subregion = country.get("subregion", "N/A")
    population = country.get("population", 0)
    area = country.get("area", 0)
    languages = ", ".join(country.get("languages", {}).values())
    currencies = ", ".join(
        f"{c['name']} ({c.get('symbol', '')})"
        for c in country.get("currencies", {}).values()
    )
    flag = country.get("flag", "")

    print(f"\n{'=' * 50}")
    print(f"  {flag}  {name}")
    print(f"{'=' * 50}")
    print(f"  Official Name : {official}")
    print(f"  Capital       : {capital}")
    print(f"  Region        : {region} / {subregion}")
    print(f"  Population    : {population:,}")
    print(f"  Area          : {area:,.0f} kmยฒ")
    print(f"  Languages     : {languages}")
    print(f"  Currencies    : {currencies}")
    print(f"{'=' * 50}")


def compare_countries(names):
    """Fetch and compare multiple countries side by side."""
    countries = []
    for name in names:
        c = fetch_country(name)
        if c:
            countries.append(c)

    if len(countries) < 2:
        print("Need at least 2 valid countries to compare.")
        return

    print(f"\n{'':>20}", end="")
    for c in countries:
        print(f"{c['name']['common']:>20}", end="")
    print()
    print("-" * (20 + 20 * len(countries)))

    # Population
    print(f"{'Population':>20}", end="")
    for c in countries:
        print(f"{c.get('population', 0):>20,}", end="")
    print()

    # Area
    print(f"{'Area (kmยฒ)':>20}", end="")
    for c in countries:
        print(f"{c.get('area', 0):>20,.0f}", end="")
    print()

    # Region
    print(f"{'Region':>20}", end="")
    for c in countries:
        print(f"{c.get('region', 'N/A'):>20}", end="")
    print()

    # Capital
    print(f"{'Capital':>20}", end="")
    for c in countries:
        cap = ", ".join(c.get("capital", ["N/A"]))
        print(f"{cap:>20}", end="")
    print()


def main():
    """Interactive country dashboard."""
    print("๐ŸŒ Country Info Dashboard")
    print("Commands: search, compare, quit\n")

    while True:
        command = input(">>> ").strip().lower()

        if command == "quit":
            print("Goodbye! ๐Ÿ‘‹")
            break
        elif command == "search":
            name = input("Country name: ").strip()
            country = fetch_country(name)
            if country:
                display_country(country)
        elif command == "compare":
            names = input("Countries (comma-separated): ").strip()
            name_list = [n.strip() for n in names.split(",")]
            compare_countries(name_list)
        else:
            print("Unknown command. Try: search, compare, quit")


if __name__ == "__main__":
    main()

Sample Output:

๐ŸŒ Country Info Dashboard
Commands: search, compare, quit

>>> search
Country name: philippines

==================================================
  ๐Ÿ‡ต๐Ÿ‡ญ  Philippines
==================================================
  Official Name : Republic of the Philippines
  Capital       : Manila
  Region        : Asia / South-Eastern Asia
  Population    : 109,581,078
  Area          : 342,353 kmยฒ
  Languages     : English, Filipino
  Currencies    : Philippine peso (โ‚ฑ)
==================================================

>>> compare
Countries (comma-separated): japan, south korea, philippines

                           Japan         South Korea         Philippines
------------------------------------------------------------------------
          Population 125,836,021          51,269,185         109,581,078
        Area (kmยฒ)       377,930             100,210             342,353
              Region        Asia                Asia                Asia
             Capital       Tokyo               Seoul              Manila

>>> quit
Goodbye! ๐Ÿ‘‹

๐Ÿ’ก Ideas to Extend This

  • Save results to a JSON file (json.dump)
  • Add a "random" command using https://restcountries.com/v3.1/all + random.choice()
  • Use argparse to accept country names as command-line arguments
  • Add population density calculation (population / area)

๐Ÿ’ช Hands-on Exercises

Exercise 1: Book Search Tool

Write a function called search_books(query, limit=5) that:

  1. Searches the Open Library Search API at https://openlibrary.org/search.json
  2. Accepts a search query and a result limit
  3. Returns a list of dicts with keys: title, author, year
  4. Handles errors gracefully (network failures, invalid responses)

Then write a main() that asks the user for a search term, calls search_books(), and prints the results in a numbered list.

๐Ÿ’ก Hint

The Open Library API accepts q and limit as query parameters. Each result in data["docs"] has title, author_name (a list), and first_publish_year. Use .get() for safe access to keys that might be missing.

โœ… Solution
import requests


def search_books(query, limit=5):
    """Search Open Library and return a list of book dicts."""
    url = "https://openlibrary.org/search.json"
    params = {"q": query, "limit": limit}

    try:
        response = requests.get(url, params=params, timeout=15)
        response.raise_for_status()
        data = response.json()
    except requests.exceptions.RequestException as e:
        print(f"Error fetching books: {e}")
        return []

    books = []
    for doc in data.get("docs", []):
        books.append({
            "title": doc.get("title", "Unknown Title"),
            "author": ", ".join(doc.get("author_name", ["Unknown"])),
            "year": doc.get("first_publish_year", "N/A"),
        })
    return books


def main():
    query = input("Search for a book: ").strip()
    if not query:
        print("No search term provided.")
        return

    print(f"\nSearching for '{query}'...\n")
    results = search_books(query)

    if not results:
        print("No results found.")
        return

    for i, book in enumerate(results, 1):
        print(f"  {i}. {book['title']}")
        print(f"     by {book['author']} ({book['year']})")


if __name__ == "__main__":
    main()

Exercise 2: GitHub User Profiler

Write a script that takes a GitHub username and uses the GitHub REST API (no authentication needed for public data) to display:

  1. The user's name, bio, location, and number of public repos
  2. Their 5 most recently updated repositories (name, description, language, stars)

Endpoints:

  • User info: https://api.github.com/users/{username}
  • User repos: https://api.github.com/users/{username}/repos (accepts sort=updated and per_page=5 params)
๐Ÿ’ก Hint

Make two separate requests.get() calls โ€” one for the user profile, one for their repos. GitHub recommends sending a User-Agent header. The repos endpoint supports sort and per_page query parameters. Star count is under stargazers_count.

โœ… Solution
import requests

GITHUB_API = "https://api.github.com"
HEADERS = {"User-Agent": "PythonCourseLearner/1.0"}


def get_user(username):
    """Fetch GitHub user profile."""
    url = f"{GITHUB_API}/users/{username}"
    response = requests.get(url, headers=HEADERS, timeout=10)
    response.raise_for_status()
    return response.json()


def get_repos(username, count=5):
    """Fetch user's most recently updated repos."""
    url = f"{GITHUB_API}/users/{username}/repos"
    params = {"sort": "updated", "per_page": count}
    response = requests.get(
        url, headers=HEADERS, params=params, timeout=10
    )
    response.raise_for_status()
    return response.json()


def display_profile(username):
    """Display a GitHub user profile and recent repos."""
    try:
        user = get_user(username)
    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 404:
            print(f"User '{username}' not found.")
        else:
            print(f"Error: {e.response.status_code}")
        return
    except requests.exceptions.RequestException as e:
        print(f"Connection error: {e}")
        return

    print(f"\n๐Ÿ‘ค {user.get('name', username)}")
    print(f"   @{user['login']}")
    if user.get("bio"):
        print(f"   {user['bio']}")
    if user.get("location"):
        print(f"   ๐Ÿ“ {user['location']}")
    print(f"   ๐Ÿ“ฆ {user['public_repos']} public repos")
    print(f"   ๐Ÿ‘ฅ {user['followers']} followers")

    try:
        repos = get_repos(username)
    except requests.exceptions.RequestException:
        print("   (Could not fetch repos)")
        return

    print(f"\n   ๐Ÿ”„ Recently Updated Repos:")
    for repo in repos:
        lang = repo.get("language") or "โ€”"
        stars = repo["stargazers_count"]
        desc = repo.get("description") or "No description"
        print(f"   โญ {stars:>4}  {repo['name']} [{lang}]")
        print(f"         {desc[:60]}")


if __name__ == "__main__":
    username = input("GitHub username: ").strip()
    if username:
        display_profile(username)

๐Ÿ“ Summary

In this lesson you learned to connect Python programs to the outside world through REST APIs. Here's what we covered:

  • APIs are interfaces that let programs talk to servers โ€” REST APIs use HTTP methods and JSON
  • The requests library provides a clean, intuitive API for HTTP: get(), post(), put(), patch(), delete()
  • JSON maps directly to Python dicts and lists โ€” use response.json() or the json module
  • Query parameters go in the params= argument; custom headers go in headers=
  • POST with json= sends data; the server typically returns 201 Created
  • Authentication uses API keys (in params or headers) or Bearer tokens โ€” keep secrets in environment variables
  • Error handling means catching Timeout, ConnectionError, and HTTPError, plus always setting timeout=
  • Retry logic with exponential backoff handles transient failures gracefully

๐Ÿ”‘ Quick Reference

Task Code
GET request requests.get(url)
POST with JSON requests.post(url, json=data)
Query params requests.get(url, params={"q": "term"})
Custom headers requests.get(url, headers={"Auth": "..."})
Set timeout requests.get(url, timeout=10)
Check success response.ok or response.raise_for_status()
Parse JSON data = response.json()
Save JSON to file json.dump(data, file, indent=2)
Env variable os.environ.get("API_KEY")

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

In the next lesson, we'll learn Database Basics with SQLite โ€” SQL fundamentals, Python's sqlite3 module, parameterized queries, and building data-backed applications. Combined with APIs, you'll be able to fetch data from the web and store it locally.

๐Ÿงช Knowledge Check

Test your understanding with these quick questions:

Question 1

What does response.raise_for_status() do?

Question 2

Which is the correct way to pass query parameters with requests?

Question 3

Why should you always set a timeout on API requests?