Why NextWhy Next
Volver a la lista
💻 Desarrollo14 min de lectura

«Apágalo en staging y ya» - el bug real que escondía la alerta ruidosa

Cada día llegaba la misma alerta de 404 de Google Play en staging. Apagar la fuente la silenciaba, pero eso y arreglar el modo de fallo eran dos problemas distintos. El 404 era un error permanente que no mejoraba con reintentos, y sin embargo se estaba reintentando junto con los 5xx.

Lo esencial

Cuando un trabajo en cola con reintentos recibe un 404 de una API externa, ese 404 suele ser una condición permanente que no mejora por reintentar. Pero si el cliente lo lanza mezclado con los 5xx y el 429 en una sola excepción, quema el presupuesto de reintentos y llena las alertas. La solución es separar los errores permanentes (4xx) de los transitorios (5xx, 429, timeout) justo en el límite del cliente, donde se lee el código de respuesta por primera vez. Y frente a una alerta ruidosa, «apagar la fuente» y «arreglar el modo de fallo» son problemas distintos: lo primero borra esta alerta puntual, lo segundo borra todos los casos en que ese fallo podría volver.

En esta página

Todos los días, a la misma hora, llegaba la misma alerta. Un ERROR de que el trabajo de reconciliación de suscripciones había fallado en staging. El cron corría a las 3:17 de la madrugada y, tres minutos después, saltaba la alerta. El stack trace siempre empezaba con la misma línea: Google Play Voided Purchases API returned 404. Se reintentaba tres veces, moría tres veces con el mismo 404, el trabajo caía al DLQ y en nuestro Slack se acumulaba una alerta roja más.

Esto no era un incidente. No se había roto nada. Solo hacía ruido. Y al decidir qué hacer con esa alerta ruidosa volví a aprender algo: «apagarla» y «arreglarla» son problemas distintos.

Por qué era un 404

La Voided Purchases API es el endpoint de Google Play que devuelve la lista de compras reembolsadas o canceladas. La usamos para conciliar a diario y detectar a quienes se reembolsan a escondidas mientras siguen usando la app. Pero que este endpoint devuelva 404 no significa «no hay datos». En voidedpurchases.list, un 404 significa que no se encuentra la aplicación (el paquete) o que la cuenta de servicio no tiene acceso a ese paquete.

La cuenta de servicio de staging no tiene acceso, en Play Console, al paquete de producción. Y no tiene por qué tenerlo: staging no es el entorno por el que circula tráfico real de reembolsos de la tienda. Así que llamar a esta API desde staging siempre da 404. Hoy, mañana, y para siempre mientras no cambie la configuración. No es un fallo transitorio que se corta y se reconecta; es una condición permanente que genera el propio entorno.

Ahí está la clave que separa los casos. Si el fallo es transitorio, reintentar tiene sentido: la red parpadea, el servidor remoto escupe un 5xx un momento, y basta con volver a llamar unos segundos después. Pero una condición permanente no mejora por reintentar. El permiso que le falta a la cuenta de servicio de staging no va a aparecer tres segundos después. Así que reintentar este 404 tres veces era puro desperdicio, y la alerta de ERROR que venía al final era puro ruido.

Por qué el código reintentaba

La causa estaba en cómo el cliente clasificaba las respuestas. listVoidedPurchases lanzaba cualquier respuesta que no fuera 200 mezclada en un solo tipo.

if (!res.ok) {
  throw AppHttpException.serviceUnavailable(
    `Google Play Voided Purchases API returned ${res.status}`,
  );
}

serviceUnavailable es el error que nuestra cola interpreta como «candidato a reintento». Así que da igual si es 404, 500 o 429: cualquier error que salga de aquí se reintenta tres veces y termina en alerta. El problema es que estos tres estados significan cosas completamente distintas. El 500 y el 429 dicen «ahora mismo no, pero puede que pronto sí»; el 404 dice «bajo esta condición, nunca». En el momento en que se mezclan en una sola excepción, un error que no gana nada con el reintento se sube igual al pipeline de reintentos.

Lo curioso es que la respuesta correcta ya estaba en el mismo archivo. getSubscriptionPurchase, del mismo cliente, separaba el 404 desde hacía tiempo.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "purchase not found");
}

Un método respetaba el significado del 404; el otro lo aplastaba. El precedente ya existía en el código: el método nuevo simplemente no lo siguió. Esta clase de asimetría suele aparecer cuando cada método se escribe en un día distinto, con una preocupación distinta en mente.

El arreglo: separar en el límite

El arreglo se hizo en dos capas, y ambas siguieron un patrón que ya existía.

Primero, se separó el 404 en el límite del cliente. El 404 pasó a lanzarse como GooglePlayApiError(404), con el significado de «no encontrado», y el resto -5xx, 429, timeout- se quedó tal cual como serviceUnavailable. Así solo queda como candidato a reintento lo que de verdad necesita reintentarse.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx·429·tiempo de espera = objetivo real de reintento */);
}

Después se definió cómo debía recibir este 404 el servicio de reconciliación. El servicio ya tenía un flujo para «si no hay clave de cuenta de servicio, se omite en silencio» (isConfigured === false). El 404 es, en esencia, la misma situación: una API que no se puede llamar en este entorno. Así que se le dio el mismo trato.

try {
  const voided = await client.listVoidedPurchases();
  // ... continúa la reconciliación
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("voided purchases 404 - sin acceso en este entorno, se omite");
    return { skippedAppNotFound: true };
  }
  throw e; // si no es 404, se relanza tal cual para el reintento normal
}

En el catch solo se absorbe el 404, se deja un log de warn y se devuelve la estadística de skip. Como no se relanza, no hay reintentos, ni DLQ, ni alerta de ERROR. En cambio, cualquier error que no sea 404 se relanza tal cual, y la lógica de reintento original sigue funcionando con normalidad. Solo se silencia lo que hacía ruido de más; el problema real sigue sonando igual de fuerte.

A esto se le sumaron pruebas: que el cliente lance GooglePlayApiError ante un 404, que la reconciliación omita ese 404, y que un error distinto de 404 se siga relanzando. Este último caso es el importante. Si al silenciar el 404 se traga por error también el 500, dejamos de ver un incidente real.

«¿Y por qué no lo apagas en staging y ya?»

Después de subir el PR, me hicieron una buena pregunta: ¿por qué no directamente no correr este trabajo en staging? Es una intuición correcta. Staging no tiene tráfico real de reembolsos, así que no hay razón para llamar a la API. Así que lo comprobé.

Apagarlo no era tan simple como parecía. El interruptor natural para desactivar esta función en staging era la presencia o ausencia de la clave de la cuenta de servicio (isConfigured), pero esa clave no era exclusiva de este trabajo. Validación de recibos de compra en la app, procesamiento del webhook de notificaciones en tiempo real de Google (RTDN), el propio procesador de reconciliación de suscripciones: toda la validación de suscripciones colgaba de la misma clave. Para probar pagos y suscripciones en staging, esa clave tiene que estar presente. Si se quita la clave para apagar este trabajo, se apaga también todo lo que sí necesitábamos probar.

Entonces, ¿por qué no crear un interruptor exclusivo solo para este trabajo? Ese interruptor no existía. Habría que introducir algo como GOOGLE_VOIDED_RECONCILE_ENABLED. Es decir, incluso «apagarlo solo en staging» termina siendo tocar código y variables de entorno. Nunca hubo una forma gratuita de apagarlo.

Apagar y arreglar son problemas distintos

Pero el punto real no era la dificultad. Aunque crear ese interruptor hubiera sido fácil, no habría reemplazado el arreglo del 404, porque ambos enfoques resuelven problemas distintos.

  • Sacar el 404 del conjunto de candidatos a reintento es una defensa de fondo. En cualquier entorno -staging, producción con una configuración mal puesta, un futuro en el que alguien se olvide de activar el interruptor- evita que un 404 que no mejora con el reintento se convierta en una avalancha de alertas.
  • Apagar el trabajo en staging es una optimización operativa: «en este entorno no hay razón para llamarlo, así que nos ahorramos la llamada». Es una buena decisión, pero para sumar encima de lo anterior.

El orden importa. Añadir el interruptor mientras se mantiene la defensa del 404 está bien. Pero poner solo el interruptor y quitar la defensa del 404 significa que, el día en que la configuración se desalinee en producción y aparezca el 404, la alerta va a volver a estallar. Y ese día ya no se podrá descartar diciendo «es staging, no importa». Apagar la fuente borra la alerta puntual que se ve ahora mismo; arreglar el modo de fallo borra todos los casos en que esa alerta podría volver a aparecer.

Frente a una alerta ruidosa, la mano va primero al interruptor: apaguemos este trabajo, borremos esta regla de alerta, saquémoslo de este entorno. Es un impulso natural, y a veces es el correcto. Pero antes de apagar algo hay que hacerse una pregunta: ¿estoy apagando la alerta, o estoy arreglando el modo de fallo que la alerta señalaba? Lo primero silencia esta alerta; lo segundo evita que este mismo fallo vuelva con otra cara.

Lo que hay que preguntarse en el límite

Esta historia no es solo de la API de Google Play. Todo código que llama a algo externo y pone sus fallos en una cola con reintentos llega a la misma bifurcación. Antes de tocar la política de reintentos en la configuración de la cola, hay que preguntar primero en el límite del cliente.

¿Este error mejora con el reintento? El 5xx, el 429 y el timeout, casi siempre sí: ahora mismo falla, pero puede funcionar pronto. El 4xx, casi nunca: 404, 403 y 400 dan la misma respuesta mientras no cambie la condición de fondo. Si se mezclan los dos en una sola excepción, un fallo que no gana nada con el reintento termina quemando el presupuesto de reintentos y llenando las alertas. El lugar donde hay que separarlos no es la cola, sino el límite del cliente, donde se lee primero el código de respuesta.

Y antes de arreglar, no olvides revisar el propio código base una vez. En nuestro caso, el método vecino en el mismo archivo ya trataba el 404 correctamente. No hacía falta inventar nada nuevo. Pasa más seguido de lo que parece: la respuesta ya está en el código, y el código nuevo simplemente no la siguió.

Preguntas frecuentes

Mi trabajo en segundo plano sigue reintentando por un 404 y la alerta de fallo se repite sin parar. ¿Qué hago?

Primero hay que distinguir si ese 404 es transitorio o permanente. Si el recurso simplemente no existe en ese entorno, o no hay permiso de acceso, reintentar siempre dará el mismo resultado. En ese caso lo correcto es que el cliente separe el 404 en un tipo de error propio justo donde lee por primera vez el código de respuesta, de modo que el trabajo no lo relance sino que lo omita con un log de warn (skip controlado). Así se evitan los reintentos, el DLQ y la alerta de ERROR.

¿Cómo se distingue un error transitorio de uno permanente?

Por lo general, 5xx, 429 y timeout son transitorios y candidatos a reintento: ahora mismo falla, pero puede funcionar en breve. En cambio los 4xx como 404, 403 o 400 dan la misma respuesta mientras no cambie la solicitud o la condición de fondo, así que reintentar no sirve de nada. Esta distinción conviene hacerla en el límite del cliente, donde se lee primero el código de respuesta, y no en la política de reintentos de la cola.

Artículos relacionados