substrat.cat
Integracions

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 Problema transitori
429 Too Many Requests Sí, esperant Has passat el límit
500, 502, 503, 504 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:

  1. Què passa si l’altra banda triga 60 segons?
  2. Què passa si el procés s’executa dues vegades?
  3. On queda apuntat el que ha fallat, i com es torna a intentar?
  4. Qui s’assabenta que ha fallat, i quan?
  5. 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.

El següent pas

Tens un procés
que odies fer?

Explica-m'ho i et diré si es pot automatitzar — i si no es pot, també t'ho diré. La primera conversa no es cobra — però el cafè el poses tu.

hola@substrat.cat