Descarga masiva del SAT en producción: pipeline para bajar los CFDI de muchos RFC (despacho), en Python — Cesar Ayala
← Todos los artículos

Descarga masiva del SAT en producción: pipeline para bajar los CFDI de muchos RFC (despacho), en Python

Modela cada consulta al SAT como un job durable — uno por (RFC, ventana, tipo). Manda SolicitaDescarga UNA vez, persiste el IdSolicitud y polea VerificaSolicitud con backoff; nunca reenvíes una solicitud idéntica (esa es la trampa 5005 duplicada → 5002 se agotaron las solicitudes de por vida). Baja los paquetes dentro de sus 72h, deduplica por UUID y sincroniza incrementalmente, pidiendo solo ventanas nuevas.

La respuesta corta: cada consulta al SAT es un job durable, no una llamada dentro de un for

Modela cada consulta al SAT como un job durable — uno por cada combinación de (RFC, ventana de fechas, tipo). Manda SolicitaDescarga una sola vez, persiste el IdSolicitud que te regresa y hazle polling a VerificaSolicitud con backoff; nunca reenvíes una solicitud idéntica (esa es la trampa: 5005 solicitud duplicada, que termina en 5002 se agotaron las solicitudes de por vida). Baja los paquetes dentro de su ventana de 72 horas, deduplica por UUID y sincroniza de forma incremental, pidiendo solo ventanas nuevas. Así escala a decenas de RFC sin romperse.

Antes que nada: soy ingeniero, no tu contador. La arquitectura es mía; las reglas fiscales y los códigos vigentes los verificas tú contra el SAT antes de producción.

La realidad de un despacho: N clientes × emitidas/recibidas × Metadata/CFDI

En un tutorial bajas los CFDI de un RFC en el camino feliz. Un despacho no vive ahí. Por cada cliente quieres emitidas y recibidas, y muchas veces primero Metadata (ligera) y luego el CFDI completo: decenas o cientos de RFC × dos roles × dos tipos. Es una explosión de solicitudes concurrentes contra el mismo servicio.

Si tratas cada celda de esa matriz como una llamada síncrona dentro de un for, el proceso se cae al primer reinicio, al primer timeout o al primer RFC que se queda en EnProceso media hora. La única forma de sobrevivir en producción es tratar cada celda como un registro en una tabla, con su propio estado.

Por qué el for ingenuo revienta en producción

El error número uno del que integra por primera vez: como SolicitaDescarga responde CodEstatus 5000 “Solicitud Aceptada”, la gente cree que ya quedó y vuelve a mandar la misma solicitud en el siguiente ciclo. Pero 5000 es solo el acuse de recibo — no significa que los paquetes estén listos. Para saber cuándo lo están tienes que hacer polling a VerificaSolicitud con el IdSolicitud y leer el EstadoSolicitud.

Reenviar la misma SolicitaDescarga te lleva directo al pozo:

Reenvías SolicitaDescarga idéntica
5005 Solicitud duplicada
5002 Se agotaron las solicitudes de por vida
Ese rango queda bloqueado

5005 Solicitud duplicada significa que ya existe una solicitud con los mismos parámetros (FechaInicial, FechaFinal, RfcEmisor, RfcReceptor, TipoSolicitud) — el SAT te dice “reutiliza su IdSolicitud, no crees otra”. Si insistes, caes en 5002 se agotaron las solicitudes de por vida: un tope de por vida para esa consulta exacta. Tengo el desglose completo en el post de códigos de error y “solicitud aceptada”. El patrón correcto es al revés: emites SolicitaDescarga una vez, guardas el IdSolicitud, y de ahí en adelante solo consultas VerificaSolicitud.

Modela cada solicitud como un job con máquina de estados

La pieza central es una tabla de jobs. Cada renglón es una combinación única (RFC, ventana, tipo), y su IdSolicitud vive junto a su EstadoSolicitud. Así reinicias el proceso a media descarga y reanudas exactamente donde ibas — la misma mentalidad de idempotencia y outbox que uso para el timbrado con outbox.

from dataclasses import dataclass, field
from datetime import datetime, timedelta

# EstadoSolicitud del SAT (VerificaSolicitud):
# 1 Aceptada · 2 EnProceso · 3 Terminada · 4 Error · 5 Rechazada · 6 Vencida
ACEPTADA, EN_PROCESO, TERMINADA, ERROR, RECHAZADA, VENCIDA = 1, 2, 3, 4, 5, 6

@dataclass
class DownloadJob:
    rfc: str
    fecha_inicial: str          # "2026-01-01T00:00:00"
    fecha_final: str            # "2026-01-31T23:59:59"
    tipo_solicitud: str         # "Metadata" o "CFDI"
    rol: str                    # "emisor" o "receptor"
    id_solicitud: str | None = None      # se llena tras SolicitaDescarga
    estado_solicitud: int | None = None  # se actualiza con VerificaSolicitud
    intentos: int = 0
    creado_en: datetime = field(default_factory=datetime.utcnow)
    vence_en: datetime | None = None     # creado + 72h una vez Terminada
    paquetes: list[str] = field(default_factory=list)

    @property
    def clave(self) -> str:
        # clave idempotente = los mismos 5 parámetros que el SAT usa para 5005
        return f"{self.rfc}|{self.fecha_inicial}|{self.fecha_final}|{self.tipo_solicitud}|{self.rol}"

La clave usa los cinco parámetros con los que el SAT decide si algo es duplicado. Antes de crear un job nuevo, buscas esa clave en tu almacén: si ya existe con un id_solicitud, reanudas el polling en vez de mandar otra SolicitaDescarga. Ese if de una línea es lo que te salva del 5005/5002.

El poller bien hecho: VerificaSolicitud con backoff exponencial

VerificaSolicitud es asíncrono: un rango amplio de un RFC de alto volumen puede quedarse en EnProceso un buen rato. Nunca hagas polling en bucle cerrado — usa backoff exponencial, limita los intentos y maneja cada EstadoSolicitud.

import time

def poll_verifica(client, job: DownloadJob, max_intentos: int = 12) -> DownloadJob:
    espera = 30  # segundos; crece exponencial, con tope
    for _ in range(max_intentos):
        r = client.verifica_solicitud(job.id_solicitud, job.rfc)
        job.estado_solicitud = r.estado_solicitud
        job.intentos += 1

        if r.estado_solicitud == TERMINADA:          # 3: los IdsPaquetes ya vienen
            job.paquetes = r.ids_paquetes
            job.vence_en = datetime.utcnow() + timedelta(hours=72)
            return job
        if r.estado_solicitud in (RECHAZADA, ERROR):  # 5 / 4: terminal, registra el código
            raise SatSolicitudError(job.clave, r.cod_estatus, r.mensaje)
        if r.estado_solicitud == VENCIDA:             # 6: pasaron las 72h, re-solicita
            job.id_solicitud = None
            return job
        # ACEPTADA (1) o EN_PROCESO (2): sigue esperando con backoff
        time.sleep(espera)
        espera = min(espera * 2, 900)  # tope de 15 min entre intentos

    raise SatTimeoutError(job.clave, "sin Terminada tras max_intentos")
  1. 1 Aceptada / 2 EnProcesoSigue esperando con backoff exponencial — nunca reenvíes SolicitaDescarga aquí.
  2. 3 TerminadaSolo aquí llegan los IdsPaquetes. Arranca las descargas y fija vence_en a +72h.
  3. 4 Error / 5 RechazadaTerminal. Registra el CodEstatus y el Mensaje; corrige antes de re-solicitar.
  4. 6 VencidaExpiraron las 72h. Limpia el IdSolicitud y vuelve a solicitar ese rango.

Fíjate en VENCIDA: no revienta, limpia el id_solicitud para que el scheduler re-solicite ese rango. Eso no es un duplicado — el resultado anterior ya expiró, así que el SAT lo acepta como consulta nueva. Por qué el polling es el modelo correcto aquí (y no webhooks) lo desarmo en webhooks vs polling vs API.

Metadata primero, CFDI después

No pidas el XML completo de entrada. Solicita Metadata primero: es mucho más ligera (hasta 1,000,000 de registros por solicitud contra 200,000 de CFDI) y te dice qué UUID existen en ese rango. Con esa lista acotas y luego pides CFDI solo de lo que necesitas conciliar.

def plan(rfc: str, ini: str, fin: str, rol: str) -> DownloadJob:
    # 1) job ligero de Metadata para descubrir qué UUID hay
    # 2) el job de CFDI se crea DESPUÉS, ya sabiendo el conteo real
    return DownloadJob(rfc, ini, fin, "Metadata", rol)

Este orden también te blinda contra el 5003 (rebasar el máximo de registros por solicitud): si la Metadata dice que un mes trae demasiados comprobantes, partes ese rango antes de pedir el CFDI pesado. El flujo aguas abajo lo cubro en validar CFDI recibidas y automatizar cuentas por pagar con Claude.

La ventana de 72h y el 5008: baja el paquete una vez y guárdalo

Cuando el job llega a TERMINADA, los paquetes viven solo ~72 horas y cada uno se descarga un máximo de 2 veces (5008 máximo de descargas permitidas). En la práctica: bájalo una vez, escríbelo en almacenamiento durable y nunca vuelvas a pegarle al SAT por ese mismo ZIP.

def descarga_paquetes(client, job: DownloadJob, store) -> None:
    if job.vence_en and datetime.utcnow() > job.vence_en:
        job.estado_solicitud = VENCIDA   # se pasaron las 72h; re-solicitar
        return
    for id_paquete in job.paquetes:
        if store.ya_tengo(id_paquete):   # idempotencia: no re-descargues
            continue
        zip_b64 = client.descarga(id_paquete, job.rfc)  # una sola vez
        store.guardar_paquete(id_paquete, zip_b64)      # persistente, no memoria

Si tratas la descarga como “reintentable” sin caché, en dos reintentos quemas tus 2 descargas y el tercer intento choca contra 5008. La idempotencia en el store (el chequeo ya_tengo) es justo lo que lo evita.

Sync incremental: un watermark de fecha por RFC

Aquí un despacho de verdad se separa del tutorial. No reconsultes rangos históricos cada corrida — eso es justo lo que fabrica solicitudes duplicadas (5005) y, a la larga, 5002. En vez de eso guarda un watermark: la última fecha ya sincronizada por cada (RFC, rol), y en cada corrida pide solo la ventana nueva.

def siguiente_ventana(store, rfc: str, rol: str, hasta: str) -> tuple[str, str] | None:
    ultimo = store.watermark(rfc, rol)          # p.ej. "2026-06-30T23:59:59"
    if ultimo is None:
        ultimo = "2026-01-01T00:00:00"          # backfill inicial una sola vez
    if ultimo >= hasta:
        return None                             # nada nuevo: no generes solicitud
    return (ultimo, hasta)

def avanza_watermark(store, job: DownloadJob) -> None:
    if job.estado_solicitud == TERMINADA:
        store.set_watermark(job.rfc, job.rol, job.fecha_final)  # solo al terminar

El watermark solo avanza cuando el job llegó a TERMINADA. Si un rango se quedó en Error o Vencida, no se mueve y ese rango se reintenta en la siguiente corrida — sin duplicar los que ya cerraron limpio.

Dedup e idempotencia: almacenamiento con clave UUID

Un CFDI puede aparecer en más de un paquete o en dos ventanas que se traslapan. La verdad canónica es el UUID (folio fiscal). Guarda cada comprobante con el UUID como clave primaria y las escrituras se vuelven naturalmente idempotentes.

def ingest_xml(store, uuid: str, xml_bytes: bytes) -> None:
    # PK = UUID -> insertar dos veces el mismo comprobante es un no-op
    store.upsert_cfdi(uuid=uuid, xml=xml_bytes)

Si necesitas un comprobante puntual y ya conoces su folio, ni armes un rango de fechas: el SAT expone SolicitaDescargaFolio, que filtra por un solo UUID — la herramienta correcta cuando un cliente te pregunta por una factura específica.

For ingenuo (revienta)

  • Reenvía SolicitaDescarga cada ciclo → 5005 → 5002
  • Descarga en memoria; se pierde al reiniciar
  • Reconsulta rangos históricos cada corrida
  • Quema las 2 descargas por paquete → 5008

Pipeline durable (aguanta)

  • SolicitaDescarga una vez; persiste IdSolicitud
  • Polling con backoff; maneja cada EstadoSolicitud
  • Watermark por RFC: solo pide ventanas nuevas
  • Baja una vez, deduplica por UUID, guarda

Agenda para muchos RFC: reparte la carga y respeta los límites

Con la máquina de estados lista, el scheduler es casi aburrido — y así lo quieres. Recorre tus RFC, genera la siguiente ventana de cada uno (o None si no hay nada nuevo), encola los jobs y deja que un pool de workers avance el polling y las descargas. Reparte la concurrencia para no saturarte, respeta el límite de vida por consulta y parte los rangos grandes en ventanas por semana o por día cuando un RFC es de alto volumen.

Por qué un rango se queda en EnProceso y los límites reales (no existe el mito del tope de “2,000 al día” en el Web Service) está en EnProceso y los límites reales. Y si aún no tienes el andamiaje SOAP con e.firma, empieza por la guía de los 4 pasos en Python.

De ingeniero, no de contador — confirma los códigos vigentes

Repito el disclaimer porque importa: yo diseño el pipeline, no dictamino la parte fiscal. Los EstadoSolicitud, los CodEstatus y las ventanas de tiempo verifícalos contra las fuentes vivas antes de producción — el SAT los ajusta.

Persistir IdSolicitud + estadocrítico
Backoff + manejo por estadocrítico
Watermark incremental por RFCalto
Dedup por UUIDalto
Partir rangos grandesmedio

Fuentes para verificar: la especificación del Web Service del SAT, la implementación de referencia phpcfdi/sat-ws-descarga-masiva (con sus helpers isAccepted(), isInProgress(), isFinished(), isExpired() — no parsees los códigos a mano) y la guía de developers.sw.com.mx. En Python, apóyate en python-satcfdi o cfdiclient.

Sigue leyendo

Este post es el pipeline multi-RFC. El resto del cluster te da las piezas: