Integracions que aguanten: reintents, idempotència i webhooks
Els patrons que separen una integració que funciona el primer dia d'una que segueix funcionant al cap d'un any, amb codi i codis d'estat concrets.
·9 min de lectura
Connectar dos sistemes és fàcil el primer dia: llegeixes la documentació, fas la crida, funciona. El problema arriba el dia 40, quan l’API de l’altra banda triga vuit segons en comptes de dos, o torna un 503 durant quinze minuts, o el teu procés s’executa dues vegades per un reintent i es dupliquen 300 comandes.
Aquest article són els sis patrons que fan la diferència. Cap és complicat; el que passa és que no es veuen fins que fallen.
1. Posa temps d’espera. Sempre.
La majoria de clients HTTP, per defecte, esperen indefinidament. Una crida que no respon deixa el teu procés penjat per sempre, i com que no peta, ningú se n’assabenta.
import httpx
client = httpx.Client(timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0))
Dos temps diferents perquè són problemes diferents: si no aconsegueixes ni connectar en 5 segons, l’altra banda és fora; si connectes però l’informe triga 30 segons a generar-se, això pot ser normal.
Un temps d’espera generós és millor que cap. Cap és el que et deixa un procés bloquejat tres dies.
2. Reintenta el que s’ha de reintentar, i res més
No tots els errors es reintenten. Reintentar un 400 Bad Request és inútil: la petició és dolenta i ho serà les tres vegades següents.
| Codi | Reintentar? | Per què |
|---|---|---|
408 Timeout |
Sí | Problema transitori |
429 Too Many Requests |
Sí, esperant | Has passat el límit |
500, 502, 503, 504 |
Sí | Problema de l’altra banda |
400, 422 |
No | La petició és incorrecta |
401, 403 |
No | Credencials; reintentar només gasta quota |
404 |
No | No hi és |
409 Conflict |
Depèn | Sovint vol dir «ja existeix»: mira’t-ho |
I errors de xarxa —connexió refusada, DNS, temps d’espera— sempre.
L’espera entre reintents
Reintentar immediatament tres vegades és pitjor que no reintentar: si l’altra banda està saturada, li estàs afegint càrrega. La solució és espera exponencial amb aleatorietat:
import random, time, httpx
REINTENTABLES = {408, 429, 500, 502, 503, 504}
def crida(client, metode, url, intents=5, **kw):
for intent in range(intents):
try:
r = client.request(metode, url, **kw)
if r.status_code not in REINTENTABLES:
r.raise_for_status()
return r
espera = _espera_indicada(r)
except (httpx.ConnectError, httpx.ReadTimeout):
espera = None
if intent == intents - 1:
raise
# Exponencial amb aleatorietat completa: 0-1s, 0-2s, 0-4s, 0-8s…
time.sleep(espera if espera is not None else random.uniform(0, min(60, 2 ** intent)))
def _espera_indicada(resposta):
"""Si el servidor diu quan tornar, li fem cas."""
cap = resposta.headers.get('Retry-After')
return float(cap) if cap and cap.isdigit() else None
Dues coses d’aquí:
L’aleatorietat no és decorativa. Si tens 200 processos que fallen alhora i tots esperen exactament 2 segons, tornaran a colpejar l’API alhora als 2 segons. Amb random.uniform(0, 2**n) s’escampen sols. Es diu jitter i és la diferència entre recuperar-se i tornar a tombar el servidor.
La capçalera Retry-After mana. Quan una API respon 429 i et diu quant has d’esperar, aquest número és millor que qualsevol càlcul teu. Fer-li cas també és el que evita que et bloquegin.
3. Idempotència: el patró que evita els duplicats
Aquest és el més important de l’article.
Imagina que envies una comanda i la connexió cau després que el servidor l’hagi processat però abans que et respongui. Tu veus un error. Reintentes. Ara hi ha dues comandes.
No hi ha manera d’evitar-ho amb reintents: des del teu costat, «he fallat abans d’arribar» i «he fallat després d’arribar» són indistingibles. La solució ha d’estar a l’altra banda, i es diu clau d’idempotència.
import uuid
clau = str(uuid.uuid4()) # una per operació lògica, NO per intent
for intent in range(5):
r = client.post('https://api.example.com/comandes',
json=dades,
headers={'Idempotency-Key': clau})
El servidor guarda la clau amb el resultat. Si li arriba dues vegades, la segona no crea res: torna la resposta de la primera. Stripe, Adyen i la majoria d’APIs de pagament ho implementen així.
El punt crític és que la clau es genera un cop per operació, no per intent. Si la generes dins del bucle, no serveix absolutament de res. És l’error més comú.
Quan l’altra banda no ho suporta
Moltes APIs no tenen claus d’idempotència. Llavors t’has de protegir tu, i hi ha dues maneres:
Comprova abans de crear. Busca per una clau natural —el teu número de comanda, per exemple— i només crea si no existeix. No és perfecte (hi ha una escletxa entre la consulta i la creació), però tapa la gran majoria de casos.
Porta el teu registre. Una taula on apuntes què has enviat i amb quin resultat, abans i després de cada enviament:
CREATE TABLE enviaments (
referencia TEXT PRIMARY KEY, -- la teva clau, no la seva
estat TEXT NOT NULL, -- pendent | fet | fallit
id_extern TEXT,
intents INT DEFAULT 0,
darrer_error TEXT,
actualitzat TIMESTAMP
);
Amb referencia com a clau primària, la base de dades t’impedeix enviar dues vegades el mateix encara que el codi ho intenti. I quan alguna cosa surt malament, tens la llista exacta del que ha quedat a mitges — que és la pregunta que sempre acaba fent algú.
4. Límits de ritme: no esperis el 429
Reaccionar al 429 funciona, però és brut: gastes crides per rebre errors. Si l’API et diu el seu límit, respecta’l des del principi.
import time
from threading import Lock
class Ritme:
"""Com a molt `n` crides per `segons`."""
def __init__(self, n, segons):
self.n, self.segons = n, segons
self.marques, self.pany = [], Lock()
def espera(self):
with self.pany:
ara = time.monotonic()
self.marques = [t for t in self.marques if ara - t < self.segons]
if len(self.marques) >= self.n:
time.sleep(self.segons - (ara - self.marques[0]))
self.marques.append(time.monotonic())
ritme = Ritme(100, 60) # 100 per minut
for element in elements:
ritme.espera()
crida(client, 'POST', url, json=element)
Moltes APIs també envien capçaleres tipus X-RateLimit-Remaining i X-RateLimit-Reset. Llegir-les i frenar quan queden poques crides és més elegant que qualsevol càlcul a cegues.
5. Paginació: cursor abans que desplaçament
Quan has de baixar 40.000 registres, hi ha dues maneres i una és dolenta.
Amb desplaçament (?offset=1000&limit=100): si mentre recorres les pàgines algú crea o esborra un registre, tot es desplaça. Et saltes files o en repeteixes, i no te n’adones.
Amb cursor (?cursor=eyJpZCI6...): cada resposta et diu on continuar. És estable encara que les dades canviïn a sota.
def tots(client, url):
cursor = None
while True:
r = crida(client, 'GET', url, params={'cursor': cursor, 'limit': 200})
dades = r.json()
yield from dades['items']
cursor = dades.get('next_cursor')
if not cursor:
break
Fer-ho amb un generador (yield from) evita carregar-ho tot a memòria. Si l’API només ofereix desplaçament, ordena per un camp immutable i creixent —normalment l’identificador— i pagina per id > darrer_vist.
6. Webhooks: cinc regles
Rebre esdeveniments és l’altra meitat d’una integració, i té les seves pròpies trampes.
Verifica la signatura. Una URL pública és una porta oberta. Gairebé totes les plataformes envien una capçalera amb un HMAC del cos:
import hmac, hashlib
def signatura_valida(cos: bytes, rebuda: str, secret: str) -> bool:
esperada = hmac.new(secret.encode(), cos, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, rebuda)
compare_digest i no ==: la comparació normal surt abans al primer caràcter diferent i això, mesurat prou vegades, deixa endevinar la signatura.
Signa el cos cru. Si el teu marc de treball et dona el JSON ja convertit a objecte i el tornes a serialitzar per verificar-lo, un espai de diferència invalida la signatura. Necessites els bytes tal com han arribat.
Respon de seguida. Un 200 en menys d’un parell de segons, i el processament després. Si tardes, l’emissor considera que has fallat i t’ho reenvia — i acabes amb el mateix esdeveniment cinc vegades perquè el processes lentament.
@app.post('/webhook')
async def webhook(request):
cos = await request.body()
if not signatura_valida(cos, request.headers.get('X-Signature', ''), SECRET):
return Response(status_code=401)
await cua.posa(cos) # a processar més tard
return Response(status_code=200)
Compta que arribaran repetits i desordenats. La garantia habitual és com a mínim un cop, no exactament un cop. Guarda l’identificador de l’esdeveniment i ignora els que ja has vist. I no assumeixis que creat arriba abans que actualitzat: si l’esdeveniment porta marca de temps o número de versió, descarta el que sigui més antic que el que ja tens.
Reconcilia periòdicament. Els webhooks es perden: caigudes, desplegaments, la teva API fora dos minuts. Un procés que cada nit compari els dos costats i arregli les diferències no és redundant, és l’única cosa que garanteix que els sistemes acabin quadrant. És la peça que gairebé mai es fa i la que sempre acaba fent falta.
El que has de poder respondre
Una integració està acabada quan pots contestar aquestes cinc preguntes sense mirar el codi:
- Què passa si l’altra banda triga 60 segons?
- Què passa si el procés s’executa dues vegades?
- On queda apuntat el que ha fallat, i com es torna a intentar?
- Qui s’assabenta que ha fallat, i quan?
- Com sé, demà, que els dos sistemes diuen el mateix?
Si alguna no té resposta, aquesta és la següent feina — abans d’afegir cap funcionalitat nova.