HTTP & APIs

Infrastructure automation means talking to APIs. Master HTTP clients and REST patterns.

httpx Basics

httpx is the modern choice - supports sync and async, HTTP/2, and is well-designed.

Installation

uv add httpx

Simple Requests

import httpx

# GET
response = httpx.get("https://api.example.com/endpoints")
print(response.status_code)  # 200
print(response.json())       # Parsed JSON

# POST with JSON body
response = httpx.post(
    "https://api.example.com/endpoints",
    json={"mac": "00:11:22:33:44:55", "group": "Employees"}
)

# PUT
response = httpx.put(
    "https://api.example.com/endpoints/123",
    json={"group": "Guests"}
)

# DELETE
response = httpx.delete("https://api.example.com/endpoints/123")

Response Handling

import httpx

response = httpx.get("https://api.example.com/data")

# Status
response.status_code      # 200
response.is_success       # True (2xx)
response.is_error         # False
response.is_redirect      # False

# Headers
response.headers          # Headers dict
response.headers["content-type"]

# Content
response.text             # String content
response.content          # Bytes
response.json()           # Parsed JSON

# Raise on error
response.raise_for_status()  # Raises HTTPStatusError if 4xx/5xx

Client Sessions

Use a client for multiple requests - it reuses connections.

import httpx

# Context manager (recommended)
with httpx.Client() as client:
    r1 = client.get("https://api.example.com/users")
    r2 = client.get("https://api.example.com/endpoints")
# Connection closed automatically

# Reusable client
client = httpx.Client(
    base_url="https://ise-01:9060",
    timeout=30.0,
    verify=False  # Disable SSL verification (dev only)
)

try:
    response = client.get("/ers/config/endpoint")
finally:
    client.close()

Configuration

import httpx

client = httpx.Client(
    base_url="https://ise-01:9060/ers/config",
    timeout=httpx.Timeout(
        connect=5.0,
        read=30.0,
        write=10.0,
        pool=5.0
    ),
    headers={
        "Accept": "application/json",
        "Content-Type": "application/json"
    },
    verify=False,  # SSL verification
    follow_redirects=True,
    http2=True  # Enable HTTP/2
)

# Request with client
response = client.get("/endpoint")  # Full URL: https://ise-01:9060/ers/config/endpoint

Authentication

Basic Auth

import httpx

# Simple
response = httpx.get(
    "https://api.example.com/data",
    auth=("username", "password")
)

# With client
client = httpx.Client(
    base_url="https://ise-01:9060/ers/config",
    auth=("admin", "password")
)

Bearer Token

import httpx

# Manual header
response = httpx.get(
    "https://api.example.com/data",
    headers={"Authorization": "Bearer eyJhbGc..."}
)

# Custom auth class
class BearerAuth(httpx.Auth):
    def __init__(self, token: str):
        self.token = token

    def auth_flow(self, request):
        request.headers["Authorization"] = f"Bearer {self.token}"
        yield request

client = httpx.Client(auth=BearerAuth("eyJhbGc..."))

API Key

import httpx

# Header-based
client = httpx.Client(
    headers={"X-API-Key": "your-api-key"}
)

# Query parameter
response = httpx.get(
    "https://api.example.com/data",
    params={"api_key": "your-api-key"}
)

OAuth2 / Token Refresh

import httpx
from datetime import datetime, timedelta

class OAuth2Client:
    def __init__(self, token_url: str, client_id: str, client_secret: str):
        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.access_token: str | None = None
        self.token_expiry: datetime | None = None

    def get_token(self) -> str:
        """Get valid access token, refreshing if needed."""
        if self._token_valid():
            return self.access_token

        response = httpx.post(
            self.token_url,
            data={
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret
            }
        )
        response.raise_for_status()
        data = response.json()

        self.access_token = data["access_token"]
        expires_in = data.get("expires_in", 3600)
        self.token_expiry = datetime.now() + timedelta(seconds=expires_in - 60)

        return self.access_token

    def _token_valid(self) -> bool:
        return (
            self.access_token is not None
            and self.token_expiry is not None
            and datetime.now() < self.token_expiry
        )

# Usage
oauth = OAuth2Client(
    token_url="https://auth.example.com/oauth/token",
    client_id="my-client",
    client_secret="secret"
)

client = httpx.Client(
    headers={"Authorization": f"Bearer {oauth.get_token()}"}
)

REST Patterns

CRUD Operations

import httpx
from dataclasses import dataclass, asdict

@dataclass
class Endpoint:
    mac: str
    group: str
    description: str = ""

class EndpointAPI:
    def __init__(self, base_url: str, auth: tuple[str, str]):
        self.client = httpx.Client(
            base_url=base_url,
            auth=auth,
            headers={"Accept": "application/json", "Content-Type": "application/json"}
        )

    def list(self, page: int = 1, size: int = 100) -> list[dict]:
        """GET /endpoints"""
        response = self.client.get("/endpoints", params={"page": page, "size": size})
        response.raise_for_status()
        return response.json()["resources"]

    def get(self, id: str) -> dict | None:
        """GET /endpoints/{id}"""
        response = self.client.get(f"/endpoints/{id}")
        if response.status_code == 404:
            return None
        response.raise_for_status()
        return response.json()

    def create(self, endpoint: Endpoint) -> str:
        """POST /endpoints"""
        response = self.client.post("/endpoints", json=asdict(endpoint))
        response.raise_for_status()
        # Return ID from Location header
        return response.headers["Location"].split("/")[-1]

    def update(self, id: str, endpoint: Endpoint) -> None:
        """PUT /endpoints/{id}"""
        response = self.client.put(f"/endpoints/{id}", json=asdict(endpoint))
        response.raise_for_status()

    def delete(self, id: str) -> bool:
        """DELETE /endpoints/{id}"""
        response = self.client.delete(f"/endpoints/{id}")
        return response.status_code == 204

    def close(self):
        self.client.close()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()

# Usage
with EndpointAPI("https://ise-01:9060/ers/config", ("admin", "password")) as api:
    endpoints = api.list()
    endpoint = api.get("123")
    new_id = api.create(Endpoint(mac="00:11:22:33:44:55", group="Employees"))

Pagination

import httpx
from typing import Iterator

def paginate(client: httpx.Client, url: str, page_size: int = 100) -> Iterator[dict]:
    """Iterate through paginated API results."""
    page = 1
    while True:
        response = client.get(url, params={"page": page, "size": page_size})
        response.raise_for_status()
        data = response.json()

        resources = data.get("resources", [])
        for item in resources:
            yield item

        # Check if more pages
        total = data.get("total", 0)
        if page * page_size >= total:
            break
        page += 1

# Usage
client = httpx.Client(base_url="https://ise-01:9060/ers/config", auth=("admin", "pass"))
for endpoint in paginate(client, "/endpoint"):
    print(endpoint["mac"])

Error Handling

import httpx

class APIError(Exception):
    def __init__(self, status_code: int, message: str, response: httpx.Response):
        self.status_code = status_code
        self.message = message
        self.response = response
        super().__init__(f"{status_code}: {message}")

class APIClient:
    def __init__(self, base_url: str, auth: tuple[str, str]):
        self.client = httpx.Client(base_url=base_url, auth=auth)

    def request(self, method: str, url: str, **kwargs) -> dict:
        """Make request with error handling."""
        try:
            response = self.client.request(method, url, **kwargs)
        except httpx.ConnectError as e:
            raise APIError(0, f"Connection failed: {e}", None)
        except httpx.TimeoutException as e:
            raise APIError(0, f"Request timed out: {e}", None)

        if response.is_success:
            return response.json() if response.content else {}

        # Parse error message from response
        try:
            error_data = response.json()
            message = error_data.get("message", error_data.get("error", str(error_data)))
        except Exception:
            message = response.text or f"HTTP {response.status_code}"

        raise APIError(response.status_code, message, response)

    def get(self, url: str, **kwargs) -> dict:
        return self.request("GET", url, **kwargs)

    def post(self, url: str, **kwargs) -> dict:
        return self.request("POST", url, **kwargs)

# Usage
client = APIClient("https://ise-01:9060/ers/config", ("admin", "password"))
try:
    data = client.get("/endpoint/invalid-id")
except APIError as e:
    if e.status_code == 404:
        print("Endpoint not found")
    else:
        print(f"API error: {e}")

Retry Logic

import httpx
import time
from functools import wraps

def retry(max_attempts: int = 3, backoff: float = 1.0, exceptions: tuple = (httpx.TransportError,)):
    """Decorator for retry with exponential backoff."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_error = e
                    if attempt < max_attempts - 1:
                        sleep_time = backoff * (2 ** attempt)
                        time.sleep(sleep_time)
            raise last_error
        return wrapper
    return decorator

class APIClient:
    @retry(max_attempts=3, backoff=1.0)
    def get(self, url: str) -> dict:
        response = self.client.get(url)
        response.raise_for_status()
        return response.json()

Complete API Client

import httpx
from dataclasses import dataclass
from typing import Iterator, Any
import time

@dataclass
class APIConfig:
    base_url: str
    username: str
    password: str
    verify_ssl: bool = True
    timeout: float = 30.0
    max_retries: int = 3

class ISEClient:
    """ISE ERS API client."""

    def __init__(self, config: APIConfig):
        self.config = config
        self.client = httpx.Client(
            base_url=f"{config.base_url}/ers/config",
            auth=(config.username, config.password),
            verify=config.verify_ssl,
            timeout=config.timeout,
            headers={
                "Accept": "application/json",
                "Content-Type": "application/json"
            }
        )

    def _request(self, method: str, url: str, **kwargs) -> dict:
        """Make request with retry logic."""
        last_error = None
        for attempt in range(self.config.max_retries):
            try:
                response = self.client.request(method, url, **kwargs)
                response.raise_for_status()
                return response.json() if response.content else {}
            except httpx.TransportError as e:
                last_error = e
                if attempt < self.config.max_retries - 1:
                    time.sleep(2 ** attempt)
            except httpx.HTTPStatusError as e:
                if e.response.status_code in (502, 503, 504):
                    last_error = e
                    if attempt < self.config.max_retries - 1:
                        time.sleep(2 ** attempt)
                else:
                    raise
        raise last_error

    def get_endpoints(self, page_size: int = 100) -> Iterator[dict]:
        """Get all endpoints with pagination."""
        page = 1
        while True:
            data = self._request("GET", "/endpoint", params={"page": page, "size": page_size})
            resources = data.get("SearchResult", {}).get("resources", [])

            for resource in resources:
                # Get full endpoint details
                endpoint = self._request("GET", f"/endpoint/{resource['id']}")
                yield endpoint.get("ERSEndPoint", endpoint)

            total = data.get("SearchResult", {}).get("total", 0)
            if page * page_size >= total:
                break
            page += 1

    def get_endpoint(self, id: str) -> dict | None:
        """Get single endpoint by ID."""
        try:
            response = self._request("GET", f"/endpoint/{id}")
            return response.get("ERSEndPoint", response)
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                return None
            raise

    def get_endpoint_by_mac(self, mac: str) -> dict | None:
        """Get endpoint by MAC address."""
        data = self._request("GET", "/endpoint", params={"filter": f"mac.EQ.{mac}"})
        resources = data.get("SearchResult", {}).get("resources", [])
        if resources:
            return self.get_endpoint(resources[0]["id"])
        return None

    def create_endpoint(self, mac: str, group: str, **kwargs) -> str:
        """Create new endpoint, return ID."""
        payload = {
            "ERSEndPoint": {
                "mac": mac,
                "groupId": self._get_group_id(group),
                **kwargs
            }
        }
        response = self.client.post("/endpoint", json=payload)
        response.raise_for_status()
        return response.headers["Location"].split("/")[-1]

    def delete_endpoint(self, id: str) -> bool:
        """Delete endpoint by ID."""
        response = self.client.delete(f"/endpoint/{id}")
        return response.status_code == 204

    def _get_group_id(self, group_name: str) -> str:
        """Look up group ID by name."""
        data = self._request("GET", "/endpointgroup", params={"filter": f"name.EQ.{group_name}"})
        resources = data.get("SearchResult", {}).get("resources", [])
        if not resources:
            raise ValueError(f"Group not found: {group_name}")
        return resources[0]["id"]

    def close(self):
        self.client.close()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()

# Usage
config = APIConfig(
    base_url="https://ise-01:9060",
    username="admin",
    password="password",
    verify_ssl=False
)

with ISEClient(config) as client:
    # List all endpoints
    for endpoint in client.get_endpoints():
        print(f"{endpoint['mac']} - {endpoint.get('groupId')}")

    # Look up specific endpoint
    ep = client.get_endpoint_by_mac("00:11:22:33:44:55")
    if ep:
        print(f"Found: {ep}")

Next Module

Async Programming - asyncio, aiohttp, concurrent patterns.