02Arquitectura6 min

Pido cuenta para buscar anime, y sé que suena ridículo

Si cada petición te cuesta dinero, necesitas saber de quién es cada una. Probé a apañarme con IPs y cookies, y al final el login fue la clave más barata que encontré.

Por Salva Castells

Imagen de portada: Draceane, CC BY-SA 4.0.
En este post

Tengo un buscador semántico de anime. Cada búsqueda es un embedding, una llamada de reranking y una síntesis de texto. Céntimos por petición, nada que preocupe mientras la hace una persona. Otra cosa es cuando la hace un script con un bucle a las tres de la mañana.

La primera versión no pedía cuenta para nada, porque pedir registro para buscar anime me parecía (y me sigue pareciendo) un poco absurdo. Ahora hay que registrarse para pasar de unas pocas búsquedas. No lo hice por la lista de correo, que ni la tengo montada. Lo hice porque me di cuenta de que todo lo que quería controlar necesitaba una respuesta a la pregunta "¿esto de quién es?", y sin cuenta esa respuesta era siempre una aproximación.

Ya sé cuál es el primer comentario: "la fricción del registro te mata la conversión, pon un rate limit por IP y listo". Lo intenté. Va de eso la primera parte.

Por qué la IP no me valió

No en el buscador, pero sí en otro proyecto: un harness de validación fake-door con cinco landings a la vez para medir interés antes de construir nada. Lo único que había que proteger era el panel de admin, así que puse baneo de IP tras varios intentos fallidos de login. Me pareció suficiente.

Y cogía la IP del primer valor de x-forwarded-for, que es justo el que escribe el cliente. Cualquiera podía mandar la cabecera con la IP que quisiera, esquivar su baneo y, ya puestos, conseguir que banearan a otro. Lo arreglé contando desde la derecha según cuántos proxies de confianza tengo delante, con una variable TRUSTED_PROXY_HOPS. Funciona.

Pero párate a pensar lo que implica eso. Para identificar a alguien de forma aproximada he metido una variable de entorno que depende de la topología de red del despliegue. Si mañana pongo un CDN delante, el número cambia, y el sistema de baneos no falla ni lanza ningún error. Simplemente empieza a banear a la IP equivocada y yo no me entero.

Y aunque lo configures perfecto, la IP es mala clave por razones que no tienen arreglo. Cuatro personas de una oficina comparten una. Alguien con el móvil cambia de IP al salir del portal. Las operadoras móviles hacen NAT sobre bloques enormes, así que banear una IP de operador puede ser banear a medio barrio. De un identificador quieres que sea estable para la misma persona y distinto entre personas distintas. La IP no cumple ninguna de las dos, y un user_id cumple las dos sin esfuerzo.

La otra objeción razonable es que una cuenta tampoco para a nadie decidido: se crea diez. Cierto. Pero crear diez cuentas cuesta más que cambiar una cabecera, y cada una de esas diez es una fila que puedo cortar sin llevarme a nadie más por delante.

Qué acabé contando, y con qué clave

En el buscador hay tres niveles, y los tres tienen un límite finito:

visitor:    { semanticSearchLimit: 10,  historyLimit: 0 }
registered: { semanticSearchLimit: 30,  historyLimit: 10 }
premium:    { semanticSearchLimit: 200, historyLimit: null }

En el tipo dejé un comentario que para mí es la regla importante del sistema: siempre finito, ningún nivel está exento de la cuota de búsqueda. El premium tiene el número más alto, no el infinito. historyLimit sí acepta null como "ilimitado", porque guardar historial no me cuesta casi nada. Una búsqueda sí, así que no hay nivel sin techo, ni siquiera el que paga. Si esto te parece tacaño, piensa en qué pasa cuando la clave de un usuario premium acaba en un script ajeno.

Fíjate también en que el visitante existe. No es que no se pueda buscar sin cuenta; es que sin cuenta tienes diez búsquedas, con una cookie que te da una clave anónima. Eso ya es una forma de identidad, floja, que se borra con el modo incógnito, pero para diez búsquedas me vale.

Donde la cookie ya no llega es en la renovación. El registrado renueva por mes natural en UTC y el premium en el aniversario de su alta. Para eso hay que guardar un premiumAnchorAt, y para guardar cualquier cosa necesitas una fila con dueño. Sin cuenta no hay aniversario al que anclarse. Esa fue, curiosamente, la razón que me convenció más: no la seguridad, sino que había cosas que simplemente no se podían modelar.

La contabilidad falla de formas aburridas

Cuando cada acción cuesta dinero real, la cuota deja de ser una defensa y pasa a ser contabilidad. Y la contabilidad casi nunca falla por un ataque; falla por bugs tontos que, eso sí, solo puedes arreglar si sabes a quién le estás contando.

En el buscador, dos peticiones idénticas se contaban dos veces. La solución fue una reserva con un hash del contenido de la petición: la query normalizada, el límite, el flag de explícito y los filtros ordenados. Ordenados porque el mismo conjunto de filtros elegido en otro orden es la misma búsqueda. Y dentro del hash porque el mismo texto con otro género es otra búsqueda, y fundirlas devolvería resultados equivocados. La reserva devuelve reserved, duplicate, in_progress o exhausted, y no es que me pusiera exhaustivo, es que los cuatro casos me pasaron. (La parte de concurrencia de los contadores da para otro post entero, así que aquí la dejo.)

En una API de transcripción, el cobro por caracteres empezó siendo el texto transcrito más el procesado dividido entre diez. Cuando medí el coste real, la línea quedó así:

transcriptionText.length + Math.floor(processedText.length * 0.25)

De /10 a * 0.25, dos veces y media más. El precio estaba mal por ese factor y estuve meses pagando la diferencia sin saberlo, porque no tenía montada la medición que me lo habría enseñado.

En un SaaS de generación de imágenes fue al revés. Había créditos diarios, pero el chequeo previo solo miraba subCredits, así que gente con crédito diario disponible recibía un 402. Un dailyCredits + subCredits y arreglado. Tres bugs distintos, y los tres son columnas de una fila de usuario. Sin esa fila no habría habido bug, pero porque no habría habido nada que contar.

Tener cuenta tampoco es tener permiso

Lo último, porque me pilló por sorpresa. En una API con claves, la fila de la key sobrevivía al estado de la cuenta del dueño. En el offboarding se limpiaba el registro de facturación, pero nadie tocaba el isActive de la key, y la key seguía autenticando tan tranquila. Lo arreglé revalidando dentro de authenticateApiKey que el dueño sigue teniendo registro de facturación, en vez de fiarme del flag:

if (!ownerBilling) {
  return { authenticated: false, error: "API key owner is no longer active" };
}

Lo puse ahí y no en cada endpoint para que ningún endpoint tenga que acordarse. Si alguna vez vuelvo a tener un flag que se supone que "sigue" el estado de otra tabla, voy a desconfiar de él de entrada.

Dónde pongo la raya

Dejo entrar sin cuenta cuando la acción no cuesta nada y no acumula estado: una ficha, un catálogo, una landing. Y dejo un nivel de visitante corto para que la gente pruebe antes de decidir, porque diez búsquedas sin registrarte me parece un trato justo, y cero me parecería antipático.

Lo que me pregunto ahora cuando diseño algo con coste por petición no es si el login molesta. Molesta, lo sé, y seguramente pierdo gente en esa pantalla (no lo he medido, y debería). Me pregunto qué voy a poner en el WHERE el día que alguien se pase de la raya. Si la respuesta es una IP, ya sé cómo acaba.