Richieste HTTP in Python
Le richieste HTTP sono alla base di qualsiasi applicazione che comunica con servizi web, API REST o qualsiasi endpoint remoto. Python offre diversi strumenti per effettuare richieste HTTP, dal modulo standard urllib fino alla libreria di terze parti requests, passando per le soluzioni asincrone come httpx e aiohttp. In questo articolo esploreremo in profondità ciascuno di questi strumenti, analizzandone sintassi, casi d'uso, gestione degli errori e pattern avanzati.
Il modulo urllib della libreria standard
Il modulo urllib fa parte della libreria standard di Python e non richiede alcuna installazione aggiuntiva. È suddiviso in sotto-moduli: urllib.request per aprire URL, urllib.parse per manipolare URL e parametri, urllib.error per la gestione delle eccezioni e urllib.response per i tipi di risposta.
Richiesta GET di base
La forma più semplice di richiesta HTTP con urllib consiste nell'aprire un URL tramite urllib.request.urlopen().
import urllib.request
# apertura della connessione all'endpoint
with urllib.request.urlopen("https://httpbin.org/get") as response:
# lettura del corpo della risposta come bytes
body = response.read()
# decodifica in stringa UTF-8
text = body.decode("utf-8")
print(text)
Il metodo urlopen() restituisce un oggetto di tipo http.client.HTTPResponse, che è anche un context manager. La chiamata a read() restituisce i byte grezzi della risposta; la decodifica va effettuata manualmente.
Accesso ai metadati della risposta
L'oggetto risposta espone attributi e metodi utili per ispezionare lo status code e gli header HTTP.
import urllib.request
with urllib.request.urlopen("https://httpbin.org/get") as response:
# codice di stato HTTP (es. 200, 404)
status_code = response.status
# motivo testuale associato al codice
reason = response.reason
# dizionario degli header della risposta
headers = dict(response.getheaders())
print(f"Status: {status_code} {reason}")
print(f"Content-Type: {headers.get('Content-Type')}")
Richiesta POST con dati nel corpo
Per inviare una richiesta POST occorre costruire un oggetto Request specificando i dati da inviare come bytes e impostando l'header Content-Type appropriato.
import urllib.request
import urllib.parse
import json
# preparazione del payload come dizionario Python
payload = {
"username": "mario",
"email": "mario@example.com"
}
# serializzazione in JSON e codifica in bytes
data = json.dumps(payload).encode("utf-8")
# costruzione della richiesta con metodo, dati e header
request = urllib.request.Request(
url="https://httpbin.org/post",
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)
with urllib.request.urlopen(request) as response:
result = json.loads(response.read().decode("utf-8"))
print(result["json"]) # corpo inviato, restituito da httpbin
Invio di dati form-encoded
Quando il server attende dati nel formato application/x-www-form-urlencoded, si utilizza urllib.parse.urlencode() per codificare il dizionario.
import urllib.request
import urllib.parse
# codifica dei parametri nel formato chiave=valore&chiave2=valore2
form_data = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": "abc123",
"client_secret": "secret"
}).encode("utf-8")
request = urllib.request.Request(
url="https://httpbin.org/post",
data=form_data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
with urllib.request.urlopen(request) as response:
print(response.read().decode("utf-8"))
Aggiunta di parametri query string
I parametri GET vanno concatenati all'URL tramite urllib.parse.urlencode() e urllib.parse.urlunparse() o, più semplicemente, con manipolazione diretta della stringa.
import urllib.request
import urllib.parse
# costruzione della query string dai parametri
params = urllib.parse.urlencode({
"q": "python http",
"page": 1,
"per_page": 10
})
# composizione dell'URL finale
base_url = "https://httpbin.org/get"
full_url = f"{base_url}?{params}"
with urllib.request.urlopen(full_url) as response:
print(response.read().decode("utf-8"))
Gestione degli errori con urllib.error
Le eccezioni di urllib sono raggruppate nel sotto-modulo urllib.error. L'eccezione principale è HTTPError, che viene sollevata per codici di errore HTTP come 404 o 500, mentre URLError gestisce problemi di rete a livello più basso.
import urllib.request
import urllib.error
try:
with urllib.request.urlopen("https://httpbin.org/status/404") as response:
print(response.read())
except urllib.error.HTTPError as error:
# errore HTTP con codice di stato
print(f"Errore HTTP: {error.code} {error.reason}")
# corpo dell'errore restituito dal server
error_body = error.read().decode("utf-8")
print(f"Corpo: {error_body}")
except urllib.error.URLError as error:
# errore di rete (DNS, timeout, connessione rifiutata)
print(f"Errore di rete: {error.reason}")
Impostazione del timeout
Il parametro timeout di urlopen() definisce il numero di secondi massimi di attesa per la risposta. Al superamento del timeout viene sollevata una socket.timeout, catturabile come urllib.error.URLError.
import urllib.request
import urllib.error
try:
# timeout di 5 secondi
with urllib.request.urlopen("https://httpbin.org/delay/10", timeout=5) as response:
print(response.read())
except urllib.error.URLError as error:
print(f"Timeout o errore: {error.reason}")
La libreria requests
La libreria requests è lo strumento più diffuso per le richieste HTTP in Python. Offre un'API ad alto livello, gestione automatica di encoding, sessioni, autenticazione e molto altro. Prima di utilizzarla occorre installarla.
pip install requests
Richiesta GET
La sintassi di requests è molto più concisa rispetto a urllib. Il metodo requests.get() restituisce un oggetto Response ricco di attributi.
import requests
# richiesta GET all'endpoint di test
response = requests.get("https://httpbin.org/get")
# codice di stato HTTP
print(response.status_code)
# header della risposta come dizionario
print(response.headers["Content-Type"])
# corpo come testo decodificato
print(response.text)
# corpo deserializzato da JSON
print(response.json())
Parametri query string
I parametri GET si passano come dizionario all'argomento params; la libreria si occupa automaticamente della codifica.
import requests
# parametri aggiunti automaticamente alla query string
params = {
"search": "python requests",
"page": 2,
"limit": 20
}
response = requests.get("https://httpbin.org/get", params=params)
# URL finale con i parametri codificati
print(response.url)
print(response.json())
Richiesta POST con JSON
Il parametro json serializza automaticamente il dizionario e imposta l'header Content-Type: application/json.
import requests
# payload inviato come JSON
payload = {
"title": "Articolo di test",
"body": "Contenuto dell'articolo",
"userId": 1
}
response = requests.post(
"https://jsonplaceholder.typicode.com/posts",
json=payload
)
print(response.status_code) # 201 Created
print(response.json())
Invio di dati form-encoded
Passando un dizionario al parametro data, la libreria lo codifica come application/x-www-form-urlencoded.
import requests
# dati inviati come form-encoded
form_fields = {
"username": "luigi",
"password": "secret123"
}
response = requests.post("https://httpbin.org/post", data=form_fields)
print(response.json()["form"])
Upload di file
L'upload di file avviene tramite il parametro files. La libreria imposta automaticamente il Content-Type multipart.
import requests
# apertura del file in modalità binaria
with open("documento.pdf", "rb") as file_handle:
# il campo "file" corrisponde al nome del campo nel form
files = {"file": ("documento.pdf", file_handle, "application/pdf")}
response = requests.post("https://httpbin.org/post", files=files)
print(response.json()["files"])
Header personalizzati e autenticazione Bearer
Gli header si passano come dizionario al parametro headers. Per l'autenticazione con token JWT o API key si usa tipicamente l'header Authorization.
import requests
# token di accesso ottenuto in precedenza
access_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
custom_headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"X-Client-Version": "1.0.0"
}
response = requests.get(
"https://httpbin.org/bearer",
headers=custom_headers
)
print(response.json())
Autenticazione Basic e Digest
La libreria supporta i metodi di autenticazione standard tramite il parametro auth.
import requests
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
# autenticazione Basic: credenziali in chiaro codificate in Base64
response_basic = requests.get(
"https://httpbin.org/basic-auth/user/passwd",
auth=HTTPBasicAuth("user", "passwd")
)
print(response_basic.status_code)
# autenticazione Digest: più sicura, usa challenge-response
response_digest = requests.get(
"https://httpbin.org/digest-auth/auth/user/passwd",
auth=HTTPDigestAuth("user", "passwd")
)
print(response_digest.status_code)
Gestione degli errori
Il metodo raise_for_status() solleva un'eccezione HTTPError automaticamente per qualsiasi codice 4xx o 5xx.
import requests
from requests.exceptions import HTTPError, ConnectionError, Timeout, RequestException
try:
response = requests.get("https://httpbin.org/status/500", timeout=10)
# solleva HTTPError se il codice di stato indica un errore
response.raise_for_status()
print(response.json())
except HTTPError as error:
print(f"Errore HTTP: {error.response.status_code}")
except ConnectionError:
# impossibile stabilire la connessione
print("Errore di connessione")
except Timeout:
# la richiesta ha superato il timeout
print("Timeout della richiesta")
except RequestException as error:
# qualsiasi altro errore di requests
print(f"Errore generico: {error}")
Sessioni e riutilizzo della connessione
L'oggetto Session mantiene parametri comuni tra più richieste (header, cookie, autenticazione) e riutilizza le connessioni TCP sottostanti grazie al keep-alive, migliorando le prestazioni.
import requests
# la sessione persiste header, cookie e configurazione
with requests.Session() as session:
# header comuni a tutte le richieste della sessione
session.headers.update({
"Authorization": "Bearer token123",
"Accept": "application/json"
})
# prima richiesta: stabilisce la connessione
response_one = session.get("https://httpbin.org/get")
print(response_one.json())
# seconda richiesta: riutilizza la connessione esistente
response_two = session.get("https://httpbin.org/headers")
print(response_two.json())
Cookie
I cookie ricevuti nella risposta vengono automaticamente memorizzati nella sessione e inviati nelle richieste successive. È possibile anche impostarli manualmente.
import requests
with requests.Session() as session:
# simulazione di login che imposta un cookie di sessione
login_response = session.post("https://httpbin.org/cookies/set/session_id/abc123")
print(login_response.cookies)
# la richiesta successiva invia automaticamente il cookie
protected_response = session.get("https://httpbin.org/cookies")
print(protected_response.json())
Timeout e retry con urllib3
È possibile configurare il retry automatico montando un HTTPAdapter con una Retry strategy dalla libreria urllib3, che è una dipendenza di requests.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def create_session_with_retry() -> requests.Session:
"""Crea una sessione con retry automatico per errori di rete."""
session = requests.Session()
retry_strategy = Retry(
total=3, # numero massimo di tentativi
backoff_factor=1, # attesa esponenziale tra i tentativi
status_forcelist=[429, 500, 502, 503, 504], # codici da ritentare
allowed_methods=["GET", "POST"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
# montaggio dell'adapter per HTTP e HTTPS
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
session = create_session_with_retry()
response = session.get("https://httpbin.org/get", timeout=10)
print(response.status_code)
Streaming di grandi risposte
Per scaricare file di grandi dimensioni senza caricarli interamente in memoria si usa il parametro stream=True e si itera sul contenuto a blocchi.
import requests
url = "https://httpbin.org/bytes/1048576" # 1 MB di dati casuali
with requests.get(url, stream=True) as response:
response.raise_for_status()
# scrittura in un file locale a blocchi di 8 KB
with open("output.bin", "wb") as output_file:
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filtra i keep-alive chunk vuoti
output_file.write(chunk)
print("Download completato")
Richieste asincrone con httpx
La libreria httpx offre un'API molto simile a requests ma con supporto nativo per il codice asincrono basato su asyncio. È la scelta ideale per applicazioni che devono effettuare molte richieste in parallelo.
pip install httpx
Client sincrono
httpx fornisce anche un client sincrono compatibile con l'API di requests, utile per sostituirlo senza refactoring profondo.
import httpx
# client sincrono, funziona come requests
with httpx.Client(timeout=10.0) as client:
response = client.get("https://httpbin.org/get", params={"lang": "python"})
print(response.status_code)
print(response.json())
Client asincrono
Il client asincrono si utilizza all'interno di funzioni async con la parola chiave await.
import httpx
import asyncio
async def fetch_data(url: str) -> dict:
"""Effettua una richiesta GET asincrona e restituisce il JSON."""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
async def main() -> None:
data = await fetch_data("https://httpbin.org/get")
print(data)
asyncio.run(main())
Richieste parallele con asyncio.gather()
La vera potenza del client asincrono emerge quando si devono effettuare molte richieste contemporaneamente. asyncio.gather() le esegue in parallelo senza bloccare il thread principale.
import httpx
import asyncio
from typing import List
async def fetch_post(client: httpx.AsyncClient, post_id: int) -> dict:
"""Scarica un singolo post dall'API di test."""
url = f"https://jsonplaceholder.typicode.com/posts/{post_id}"
response = await client.get(url)
response.raise_for_status()
return response.json()
async def fetch_all_posts(post_ids: List[int]) -> List[dict]:
"""Scarica tutti i post in parallelo con un unico client condiviso."""
async with httpx.AsyncClient(timeout=15.0) as client:
# esecuzione di tutte le coroutine in parallelo
tasks = [fetch_post(client, pid) for pid in post_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
# filtraggio dei risultati validi, escludendo le eccezioni
return [r for r in results if isinstance(r, dict)]
async def main() -> None:
posts = await fetch_all_posts(list(range(1, 21)))
print(f"Scaricati {len(posts)} post")
for post in posts[:3]:
print(f" [{post['id']}] {post['title']}")
asyncio.run(main())
Streaming asincrono
Anche lo streaming di grandi risposte è supportato in modalità asincrona con il metodo aiter_bytes().
import httpx
import asyncio
async def download_file(url: str, destination: str) -> None:
"""Scarica un file in modo asincrono e lo salva su disco."""
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with open(destination, "wb") as output_file:
# iterazione asincrona sui chunk della risposta
async for chunk in response.aiter_bytes(chunk_size=8192):
output_file.write(chunk)
asyncio.run(download_file("https://httpbin.org/bytes/65536", "output.bin"))
print("Download asincrono completato")
Richieste asincrone con aiohttp
aiohttp è una libreria asincrona matura, nata prima di httpx, molto usata in produzione. Rispetto ad httpx offre anche un server HTTP asincrono integrato.
pip install aiohttp
Richiesta GET di base
import aiohttp
import asyncio
async def get_data(url: str) -> dict:
"""Effettua una GET e restituisce il JSON della risposta."""
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
# verifica del codice di stato (solleva exception se 4xx/5xx)
response.raise_for_status()
return await response.json()
async def main() -> None:
data = await get_data("https://httpbin.org/get")
print(data)
asyncio.run(main())
Richiesta POST con JSON
import aiohttp
import asyncio
async def create_resource(payload: dict) -> dict:
"""Invia un POST con corpo JSON e restituisce la risposta."""
async with aiohttp.ClientSession() as session:
async with session.post(
"https://jsonplaceholder.typicode.com/posts",
json=payload # serializzazione e Content-Type automatici
) as response:
response.raise_for_status()
return await response.json()
async def main() -> None:
new_post = await create_resource({
"title": "Test aiohttp",
"body": "Corpo del post",
"userId": 42
})
print(new_post)
asyncio.run(main())
Richieste parallele con asyncio.gather()
import aiohttp
import asyncio
from typing import List
async def fetch_user(session: aiohttp.ClientSession, user_id: int) -> dict:
"""Scarica i dati di un singolo utente."""
async with session.get(
f"https://jsonplaceholder.typicode.com/users/{user_id}"
) as response:
response.raise_for_status()
return await response.json()
async def fetch_all_users(user_ids: List[int]) -> List[dict]:
"""Scarica tutti gli utenti condividendo la stessa ClientSession."""
async with aiohttp.ClientSession() as session:
tasks = [fetch_user(session, uid) for uid in user_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in results if isinstance(r, dict)]
async def main() -> None:
users = await fetch_all_users(list(range(1, 11)))
for user in users:
print(f"{user['id']}: {user['name']} ({user['email']})")
asyncio.run(main())
Timeout con aiohttp
aiohttp usa un oggetto ClientTimeout per configurare diversi tipi di timeout in modo granulare.
import aiohttp
import asyncio
async def get_with_timeout(url: str) -> None:
# timeout totale di 5 secondi, di connessione di 2 secondi
timeout_config = aiohttp.ClientTimeout(total=5, connect=2)
try:
async with aiohttp.ClientSession(timeout=timeout_config) as session:
async with session.get(url) as response:
print(await response.text())
except aiohttp.ServerTimeoutError:
print("Timeout lato server")
except asyncio.TimeoutError:
print("Timeout totale superato")
except aiohttp.ClientError as error:
print(f"Errore client: {error}")
asyncio.run(get_with_timeout("https://httpbin.org/delay/10"))
Pattern avanzati
Interceptor e middleware con requests
Gli Event Hooks di requests consentono di registrare callback che vengono eseguiti dopo ogni risposta, utili per logging centralizzato o per la gestione automatica del token refresh.
import requests
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_response(response: requests.Response, *args, **kwargs) -> None:
"""Hook eseguito dopo ogni risposta per registrare i dettagli."""
duration_ms = response.elapsed.total_seconds() * 1000
logger.info(
"%s %s -> %d (%.0f ms)",
response.request.method,
response.url,
response.status_code,
duration_ms
)
with requests.Session() as session:
# registrazione dell'hook di risposta
session.hooks["response"].append(log_response)
session.get("https://httpbin.org/get")
session.get("https://httpbin.org/delay/1")
Client con autenticazione OAuth2 e token refresh
Un pattern comune consiste nel wrappare il client in una classe che gestisce automaticamente il rinnovo del token alla scadenza.
import requests
import time
class OAuthClient:
"""Client HTTP con gestione automatica del token OAuth2."""
def __init__(self, token_url: str, client_id: str, client_secret: str) -> None:
self._token_url = token_url
self._client_id = client_id
self._client_secret = client_secret
self._access_token: str | None = None
self._token_expiry: float = 0.0
self._session = requests.Session()
def _is_token_expired(self) -> bool:
"""Verifica se il token è scaduto con un margine di 30 secondi."""
return time.time() >= (self._token_expiry - 30)
def _refresh_token(self) -> None:
"""Ottiene un nuovo access token tramite client credentials flow."""
response = self._session.post(self._token_url, data={
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret
})
response.raise_for_status()
token_data = response.json()
self._access_token = token_data["access_token"]
# calcolo del timestamp di scadenza
self._token_expiry = time.time() + token_data.get("expires_in", 3600)
def get(self, url: str, **kwargs) -> requests.Response:
"""Effettua una GET rinnovando il token se necessario."""
if self._is_token_expired():
self._refresh_token()
kwargs.setdefault("headers", {})
kwargs["headers"]["Authorization"] = f"Bearer {self._access_token}"
return self._session.get(url, **kwargs)
Rate limiting con time.sleep() e semafori asincroni
Quando si interagisce con API che impongono limiti di frequenza, è necessario controllare il ritmo delle richieste. In ambiente sincrono si usa time.sleep(), in ambiente asincrono si usa un asyncio.Semaphore.
import asyncio
import httpx
from typing import List
# semaforo che limita le connessioni simultanee a 5
rate_limiter = asyncio.Semaphore(5)
async def fetch_limited(client: httpx.AsyncClient, url: str) -> dict:
"""Effettua una richiesta rispettando il limite di concorrenza."""
async with rate_limiter:
response = await client.get(url)
response.raise_for_status()
# pausa tra le richieste per rispettare il rate limit dell'API
await asyncio.sleep(0.2)
return response.json()
async def fetch_batch(urls: List[str]) -> List[dict]:
"""Scarica un batch di URL con rate limiting integrato."""
async with httpx.AsyncClient(timeout=15.0) as client:
tasks = [fetch_limited(client, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in results if isinstance(r, dict)]
Paginazione automatica
Molte API REST restituiscono i risultati in pagine. Un generatore Python consente di iterare su tutte le pagine in modo trasparente.
import requests
from typing import Generator
def paginate(
session: requests.Session,
base_url: str,
page_size: int = 10
) -> Generator[list, None, None]:
"""Generatore che itera su tutte le pagine di una API paginata."""
page = 1
while True:
response = session.get(base_url, params={"page": page, "limit": page_size})
response.raise_for_status()
data = response.json()
items = data.get("data") or data # struttura variabile a seconda dell'API
if not items:
break # nessun risultato: fine della paginazione
yield items
page += 1
# alcune API indicano l'ultima pagina con un flag esplicito
if not data.get("has_more", True):
break
with requests.Session() as session:
for page_items in paginate(session, "https://reqres.in/api/users", page_size=6):
for user in page_items:
print(f"{user.get('id')}: {user.get('first_name')} {user.get('last_name')}")
Test delle richieste HTTP
Testare il codice che effettua richieste HTTP richiede di sostituire le chiamate reali con risposte simulate. La libreria responses intercetta le chiamate di requests senza modificare il codice da testare.
pip install responses
import requests
import responses
import unittest
def get_user(user_id: int) -> dict:
"""Funzione da testare: recupera un utente dall'API."""
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
class TestGetUser(unittest.TestCase):
@responses.activate
def test_successful_response(self) -> None:
"""Verifica che l'utente venga deserializzato correttamente."""
# registrazione della risposta mock
responses.add(
method=responses.GET,
url="https://api.example.com/users/1",
json={"id": 1, "name": "Mario Rossi"},
status=200
)
user = get_user(1)
self.assertEqual(user["id"], 1)
self.assertEqual(user["name"], "Mario Rossi")
@responses.activate
def test_not_found(self) -> None:
"""Verifica che un 404 sollevi HTTPError."""
responses.add(
method=responses.GET,
url="https://api.example.com/users/999",
status=404
)
with self.assertRaises(requests.HTTPError):
get_user(999)
if __name__ == "__main__":
unittest.main()
Confronto tra le librerie
La scelta della libreria dipende dalle esigenze del progetto. urllib è adatta quando non si vogliono dipendenze esterne e le richieste sono semplici. requests è la scelta standard per applicazioni sincrone grazie alla sua API intuitiva e alla vastità dell'ecosistema. httpx è preferibile quando si ha bisogno sia del client sincrono sia di quello asincrono con la stessa API, oppure quando si lavora con HTTP/2. aiohttp è la scelta consolidata per applicazioni interamente asincrone ad alto throughput, soprattutto se si ha già familiarità con il suo ecosistema.
In tutti i casi, adottare l'oggetto sessione o client, configurare timeout adeguati e gestire le eccezioni in modo granulare sono pratiche fondamentali per costruire applicazioni robuste e affidabili.