La unidad de cobro de un SaaS que scrapea no es una decisión de pricing. Es una decisión de arquitectura, y se toma antes de escribir el primer scraper, porque después condiciona todo: el caché, los reintentos, el modo asincrónico y hasta el contrato de errores.
Multita consulta infracciones de tránsito en 32 jurisdicciones argentinas (Provincia de Buenos Aires, CABA, provinciales y municipales). Cada una es un portal de gobierno distinto, con su HTML, su captcha y sus caídas. Este es el razonamiento detrás de cómo quedó definida la unidad, y qué obligó a construir.
El problema: N fuentes por consulta, y N es variable
Una consulta por patente puede tocar una jurisdicción o las 32. Hay dos formas obvias de cobrarla, y las dos son malas.
Cobrar por fuente consultada. El precio sube justo cuando el caso es más grande. Para el que integra, el costo se vuelve imposible de presupuestar: no sabe cuánto va a salir una consulta hasta después de hacerla. Y aparece un incentivo horrible, que es pedir menos fuentes de las necesarias para ahorrar, o sea empeorar el dato a propósito.
Cobrar por llamada, sin más. Suena simple hasta que aparecen los reintentos. Si un portal se cae y el sistema reintenta cinco veces, ¿quién paga esos cinco intentos? Si los paga el cliente, está pagando por la inestabilidad de un tercero. Si los paga el proveedor sin haberlo pensado, el margen se lo come la fuente más inestable.
La decisión fue: una llamada cuenta 1, sin importar cuántas jurisdicciones toque ni cuántos reintentos internos haga. Simple de explicar y de presupuestar. Pero esa promesa hay que sostenerla en el código, y ahí es donde se pone interesante.
Lo que esa promesa obliga a construir
1. Un caché que no se cobre
Si el precio es por llamada, el caché deja de ser una optimización y pasa a ser parte del modelo de negocio. En este caso el TTL es de 48 horas y el caché está aislado por organización, no global.
Lo del aislamiento no es un detalle. Son datos de deuda asociados a una patente o a un documento: dos cuentas distintas no pueden compartir nada. Dentro de una misma organización sí se comparte, y ahí el caché rinde de verdad, porque el panel web y la API comparten el mismo: una consulta desde la web deja el dato listo para el bot, y al revés.
La otra mitad es que el cliente sepa qué está recibiendo. Toda respuesta trae el estado del caché:
"cache": {
"cacheado": true,
"antiguedad_segundos": 420,
"antiguedad_texto": "hace 7 min"
}
Con eso, la integración decide sola si le alcanza el dato de hace un rato o si necesita force_refresh: true. Para deuda vencida el dato de ayer sirve perfecto, no cambia minuto a minuto. Para reflejar un pago recién hecho, no.
Un caché sin esa transparencia es una mentira silenciosa. Con ella es una feature.
2. Reintentos que no empeoren el resultado
Cada jurisdicción se reintenta hasta cinco veces, cada intento por una IP distinta de un pool rotativo y, en las que tienen captcha, resolviendo uno nuevo. Todo eso sigue contando como una sola consulta.
Además, cuando una consulta queda incompleta y sobra tiempo dentro del presupuesto del endpoint, se reconsultan solo las jurisdicciones que fallaron. Las que ya salieron bien se leen del caché recién escrito, así que el reintento solo paga el costo de las fallidas.
La regla que hace que esto sea seguro es la que más costó escribir: el reintento se adopta solo si resolvió al menos una jurisdicción y no rompió ninguna que ya estaba bien. Si no mejora, se conserva la respuesta original tal cual. Un reintento que puede empeorar el resultado no es resiliencia, es una lotería.
3. Un camino asincrónico, porque el sincrónico no da
Acá va el dato incómodo, que conviene decir antes de que lo descubra el que integra: una consulta a todas las jurisdicciones sin caché espera a la más lenta. Puede tardar de segundos a un par de minutos.
Ningún chat aguanta eso en una sola request. Así que el camino recomendado para un bot es encolar y avisar:
POST /consulta/async -> 202 { "job_id": "9f3a1c8e...", "poll_url": "..." }
El cliente responde "estamos consultando" al instante, y cuando el job termina llega un webhook firmado con HMAC (o se pollea el job_id). El campo resultado tiene exactamente la misma forma que la respuesta sincrónica, así que el código que parsea es uno solo.
Decir el peor caso en la documentación es más barato que un ticket de soporte por timeout.
4. Idempotencia, porque los clientes reintentan
Si se cobra por llamada, un reintento del lado del cliente por un timeout de red no puede contar dos veces. Un header opcional alcanza:
Idempotency-Key: <valor único por consulta>
Con eso, el reintento devuelve la respuesta original en vez de disparar una consulta nueva. Es de las cosas más baratas de implementar y de las que más discusiones de facturación evita.
El contrato que sostiene todo lo demás
Nada de lo anterior sirve si el dato no es confiable, y en un producto que agrega fuentes ajenas el riesgo más caro no es el error: es el falso vacío.
Si un portal de gobierno se cae y el sistema devuelve una lista vacía, el que integra la lee como "este auto no debe nada". Es el peor error posible, porque es silencioso y se propaga hasta el cliente final.
Por eso el contrato separa las dos cosas de forma explícita:
{
"consulta_completa": false,
"resultados": [
{ "jurisdiccion": "pba", "ok": true, "cantidad_actas": 2, "total_oficial": 142000 },
{ "jurisdiccion": "caba", "ok": false, "error": "timeout" }
]
}
ok: false significa "no pudimos ver", nunca "no debe". Y consulta_completa es el campo que hay que leer antes de afirmarle a una persona que no tiene deuda. Una jurisdicción caída no es un error HTTP: viene dentro de un 200, en su propio bloque.
Vale la pena la variante fea: hay dos municipios cuyo portal, buscando por documento y sin registros, responde un HTTP 500 en vez de una respuesta vacía. En la realidad operativa ese 500 significa "sin deuda". Se normaliza a cero actas con ok: true, pero solo para esas dos jurisdicciones y solo por documento. Cualquier otro error sigue siendo una falla real. Las normalizaciones de este tipo hay que acotarlas con nombre y apellido, o se convierten en el bug que devuelve falsos negativos en producción.
Lo que se gana cuando la unidad de cobro está bien elegida
Con la unidad definida así, el producto pudo crecer sin rediseñar el modelo. Sobre la misma llamada se apoyaron cosas bastante distintas entre sí: un modo que además de la deuda oficial devuelve el presupuesto ya calculado con el pricing de cada cliente y el PDF con su marca, monitoreo automático de listas de patentes que avisa cuando aparece una multa nueva o cambia el estado de un acta, y generación de recibos y de escritos de descargo.
Ninguna de esas features tuvo que inventar su propia unidad de cobro. Es el beneficio real de haberla decidido temprano: una consulta sigue siendo una consulta, sin importar cuánto producto se le apile encima. La contracara de la decisión, y conviene nombrarla, es que el proveedor absorbe la varianza: si mañana un portal se vuelve tres veces más lento, el costo lo come el proveedor, no el cliente. Ese es exactamente el riesgo que se está vendiendo.
El razonamiento de negocio detrás de todo esto, con la cuenta en horas y en plata, está contado aparte en cuánto le cuesta a una gestoría cotizar un caso.
Si vas a integrar algo parecido
Tres cosas que conviene dejar cerradas antes del primer scraper:
- Definí la unidad de cobro primero. Condiciona el caché, los reintentos y el modo asincrónico. Cambiarla después es cambiar el producto.
- Hacé explícita la diferencia entre "no hay" y "no pude ver". Es un campo en el JSON y te ahorra el peor tipo de bug.
- Documentá el peor caso de latencia. El que integra lo va a encontrar igual; mejor que lo encuentre leyendo.
El contrato completo está en la especificación OpenAPI, y hay una versión en texto plano pensada para pasarle a un asistente en llms-full.txt. La documentación de la API de consulta y cotización tiene los ejemplos completos.
Top comments (0)