<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Olivia Cheng</title>
    <description>The latest articles on DEV Community by Olivia Cheng (@oliviachen7).</description>
    <link>https://dev.to/oliviachen7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4013521%2Fa581805e-741a-4c70-9e67-dc5989491b5e.jpg</url>
      <title>DEV Community: Olivia Cheng</title>
      <link>https://dev.to/oliviachen7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oliviachen7"/>
    <language>en</language>
    <item>
      <title>FastAPI: clasifica fallos de correo en vez de reintentar</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Sat, 14 Nov 2026 20:23:21 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-clasifica-fallos-de-correo-en-vez-de-reintentar-176e</link>
      <guid>https://dev.to/oliviachen7/fastapi-clasifica-fallos-de-correo-en-vez-de-reintentar-176e</guid>
      <description>&lt;p&gt;Un endpoint de FastAPI que envía un correo suele empezar con una regla sencilla: si algo falla, reintenta. Funciona hasta que una dirección no existe, el proveedor responde con límite de frecuencia o la aplicación ya entregó el mensaje pero perdió la respuesta. En esos casos, repetir la misma llamada no arregla el problema; puede duplicar mensajes y esconder la causa.&lt;/p&gt;

&lt;p&gt;Cuando automatizo flujos de backend, trato el envío como una operación con estados. Una dirección de correo desechable o una dirección normal puede servir para una prueba, pero el sistema debe saber si el error es temporal, permanente o desconocido. La palabra clave &lt;code&gt;burner email address&lt;/code&gt; describe una necesidad de prueba frecuente, no una política de reintento.&lt;/p&gt;

&lt;h2&gt;
  
  
  El error no siempre significa que hay que reintentar
&lt;/h2&gt;

&lt;p&gt;El primer paso es separar el fallo de transporte del resultado de negocio. Un timeout de red puede merecer un intento adicional. Un &lt;code&gt;400&lt;/code&gt; porque falta un campo no lo merece. Un &lt;code&gt;202 Accepted&lt;/code&gt; tampoco confirma que el mensaje llegó: solo indica que el proveedor aceptó la petición.&lt;/p&gt;

&lt;p&gt;Una clasificación pequeña suele ser suficiente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;transitorio&lt;/strong&gt;: timeout, conexión cerrada o respuesta &lt;code&gt;429&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;permanente&lt;/strong&gt;: dirección inválida, credenciales rechazadas o plantilla inexistente;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ambiguo&lt;/strong&gt;: se perdió la respuesta después de enviar la petición;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;de negocio&lt;/strong&gt;: el usuario ya verificó su cuenta o la solicitud está duplicada.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sin esta separación, los fallos no es más fáciles de resolver con un bucle infinito. El reintento debe ser una decisión, no un reflejo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clasificar antes de decidir
&lt;/h2&gt;

&lt;p&gt;Podemos representar el resultado del proveedor con un modelo pequeño. No hace falta crear una jerarquía compleja de excepciones para cada código.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="nf"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;StrEnum&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;BaseModel&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeliveryClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StrEnum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="no"&gt;TRANSIENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"transient"&lt;/span&gt;
    &lt;span class="no"&gt;PERMANENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"permanent"&lt;/span&gt;
    &lt;span class="no"&gt;AMBIGUOUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ambiguous"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeliveryResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DeliveryClass&lt;/span&gt;
    &lt;span class="n"&gt;provider_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Después, la función que llama al proveedor convierte respuestas concretas en este contrato. El resto de la aplicación no necesita conocer cada peculiaridad del SDK. Esta frontera tambien facilita las pruebas: se pueden simular un &lt;code&gt;429&lt;/code&gt;, un timeout y una respuesta ambigua sin levantar un servidor de correo completo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un pequeño contrato de errores en FastAPI
&lt;/h2&gt;

&lt;p&gt;El endpoint debe devolver una respuesta estable al cliente, aunque internamente el envío sea asíncrono. Por ejemplo, una petición de verificación puede devolver &lt;code&gt;202&lt;/code&gt; con un identificador de operación. El cliente consulta el estado, y el worker decide si reintenta.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EmailStr&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VerificationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EmailStr&lt;/span&gt;


&lt;span class="nd"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/verification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;HTTP_202_ACCEPTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;request_verification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VerificationRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;operation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueue_verification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"operation_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"queued"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La cola debe conservar una clave idempotente, por ejemplo &lt;code&gt;verification:{user_id}:{purpose}&lt;/code&gt;. Así, si el worker no sabe si la primera llamada terminó, puede consultar o repetir con la misma clave. Cada reintento tienen que conservar el mismo identificador de operación; generar uno nuevo crea dos historias que luego nadie puede unir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automatizar reintentos con límites
&lt;/h2&gt;

&lt;p&gt;Un worker práctico usa tres límites: número de intentos, tiempo total y tipo de error. Un &lt;code&gt;429&lt;/code&gt; puede esperar con backoff, mientras que una dirección inválida debe terminar en una cola de revisión o en un estado visible para el usuario.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deliver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VerificationJob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;DeliveryResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classification&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;DeliveryClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;TRANSIENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeliveryResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;DeliveryClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;AMBIGUOUS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El backoff no es una garantía de entrega. Es solo una forma de no concentrar tráfico cuando el proveedor está lento. Si la respuesta sean ambiguo después del límite, guarda la operación para una reconciliación posterior; no la marques como entregada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observabilidad útil para el equipo
&lt;/h2&gt;

&lt;p&gt;Los logs deben responder tres preguntas: qué operación falló, en qué estado quedó y quién puede actuar. Registra &lt;code&gt;operation_id&lt;/code&gt;, clasificación, código del proveedor, número de intento y duración. Evita el cuerpo completo del mensaje y los tokens de verificación.&lt;/p&gt;

&lt;p&gt;También conviene medir la proporción de cada clase. Un aumento de errores permanentes puede indicar datos malos en el formulario. Muchos ambiguos pueden apuntar a un timeout demasiado corto. Sus logs queda mucho más útil cuando distinguen esas dos situaciones.&lt;/p&gt;

&lt;p&gt;En CI, la misma idea evita limpiar evidencia importante junto con datos sensibles. Puedes complementar este flujo con una guía para &lt;a href="https://dev.to/alexcarteruk/kubernetes-prueba-alertas-tras-cambios-de-red-flo"&gt;probar alertas después de un cambio de red&lt;/a&gt; y con prácticas para &lt;a href="https://dev.to/alexcarteruk/kubernetes-limpiar-datos-de-email-en-ci-sin-perder-senales-4ghb"&gt;limpiar datos de email en CI sin perder señales&lt;/a&gt;. Si una búsqueda informal dice “fake e mail com”, documenta el término como entrada de prueba, pero no lo conviertas en una decisión técnica.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist y preguntas frecuentes
&lt;/h2&gt;

&lt;p&gt;Antes de publicar el endpoint, revisa:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;¿Cada error tiene una clasificación explícita?&lt;/li&gt;
&lt;li&gt;¿El reintento está limitado por tiempo y cantidad?&lt;/li&gt;
&lt;li&gt;¿La operación tiene una clave idempotente?&lt;/li&gt;
&lt;li&gt;¿Se puede reconciliar un resultado ambiguo?&lt;/li&gt;
&lt;li&gt;¿Los logs excluyen tokens y cuerpos de correo?&lt;/li&gt;
&lt;li&gt;¿Las pruebas cubren &lt;code&gt;429&lt;/code&gt;, timeout, &lt;code&gt;400&lt;/code&gt; y duplicados?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;¿Debo reintentar cualquier error 5xx?&lt;/strong&gt; No siempre. Un &lt;code&gt;5xx&lt;/code&gt; puede ser temporal, pero si el proveedor ya aceptó la operación y solo falló la respuesta, repite únicamente con idempotencia o consulta el estado primero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Es suficiente comprobar que existe un &lt;code&gt;message_id&lt;/code&gt;?&lt;/strong&gt; No. Es una señal de aceptación, no necesariamente de entrega. Conserva el estado del proveedor cuando esté disponible y define qué significa “entregado” para tu producto.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué hago con una dirección inválida?&lt;/strong&gt; Marca el fallo como permanente, evita reintentos automáticos y muestra una acción clara. Reintentar datos que no van a cambiar solo consume cuota y ensucia las métricas.&lt;/p&gt;

&lt;p&gt;La automatización gana calidad cuando cada fallo tiene un significado operativo. En FastAPI, un contrato pequeño, una clave idempotente y límites visibles suelen aportar más que otro bucle de reintento escondido en el cliente.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>api</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: contratos de eventos para emails de prueba</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Sat, 14 Nov 2026 02:23:38 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-contratos-de-eventos-para-emails-de-prueba-3lnl</link>
      <guid>https://dev.to/oliviachen7/fastapi-contratos-de-eventos-para-emails-de-prueba-3lnl</guid>
      <description>&lt;p&gt;Probar un flujo de registro suele parecer sencillo: crear un usuario, esperar un email y abrir el enlace. En CI, sin embargo, “esperar” es una operación ambigua. El mensaje puede llegar tarde, otro worker puede leerlo primero o una prueba anterior puede dejar basura en el inbox.&lt;/p&gt;

&lt;p&gt;La solución que más me funciona es tratar cada envío como un evento con contrato propio. No hace falta construir una plataforma enorme. Con FastAPI, una clave de correlación y un pequeño registro de estados podemos hacer que un generador de correos falsos sea útil para probar el backend, no solo para mirar si apareció un mensaje.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema: esperar un email no es un contrato
&lt;/h2&gt;

&lt;p&gt;Un test que hace &lt;code&gt;sleep(5)&lt;/code&gt; solo expresa una esperanza. No sabe cuándo se solicitó el email, cuál era el destinatario esperado ni si el mensaje encontrado pertenece a la ejecución actual. Cuando falla, el equipo recibe un timeout y poco mas.&lt;/p&gt;

&lt;p&gt;Conviene separar tres momentos:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;La aplicación acepta la solicitud de envío.&lt;/li&gt;
&lt;li&gt;El proveedor o adaptador registra el evento de entrega.&lt;/li&gt;
&lt;li&gt;El test encuentra el mensaje correcto y consume el enlace.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cada momento debe tener un identificador común. Así se puede distinguir “el email todavía no llegó” de “el email llegó, pero el enlace ya fue consumido”. Esa diferencia parece pequeña, pero cambia por completo el diagnóstico.&lt;/p&gt;

&lt;p&gt;También ayuda a que la interfaz muestre &lt;a href="https://dev.to/silviutech/react-errores-inline-sin-mover-el-formulario-b84"&gt;estados de interfaz que explican el resultado&lt;/a&gt;, en vez de esconder un estado pendiente detrás de un spinner infinito.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diseñar un evento pequeño y trazable
&lt;/h2&gt;

&lt;p&gt;Un contrato mínimo puede tener esta forma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EmailStr&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EmailStr&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"queued"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;event_id&lt;/code&gt; no debe generarse de nuevo cuando se reintenta la misma operación. El cliente puede enviarlo como clave de idempotencia y el servidor lo guarda junto con el estado. Si llega la misma petición, la API devuelve el evento existente en lugar de crear dos emails.&lt;/p&gt;

&lt;p&gt;En pruebas, &lt;code&gt;kind&lt;/code&gt; también es importante. “welcome” y “verify-account” pueden terminar en el mismo inbox, pero no tienen el mismo asunto ni la misma acción. Un filtro por tipo es mas resistente que buscar solo la palabra “verify”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementación de un endpoint FastAPI
&lt;/h2&gt;

&lt;p&gt;El endpoint puede aceptar el evento y entregar un estado claro al worker que enviará el mensaje:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/email-events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;HTTP_202_ACCEPTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_email_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;email_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El ejemplo omite la base de datos y la cola para mantener la idea enfocada. En producción, la inserción y la publicación necesitan una estrategia consistente; de lo contrario puede guardarse el evento y perderse la tarea. Para muchos equipos, un outbox transaccional es un buen siguiente paso.&lt;/p&gt;

&lt;p&gt;El endpoint esperan una respuesta rápida: &lt;code&gt;202 Accepted&lt;/code&gt; significa que la solicitud fue aceptada, no que el email ya está en el inbox. Documentar esta diferencia evita que un test intente leer el mensaje inmediatamente y declare una falla que no existe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polling con identidad y límite
&lt;/h2&gt;

&lt;p&gt;El test debe buscar por &lt;code&gt;event_id&lt;/code&gt;, destinatario, tipo y una ventana de tiempo. Un bucle pequeño es más confiable que un &lt;code&gt;sleep&lt;/code&gt; fijo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"Email no encontrado: {event.event_id}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ocho intentos de un segundo son solo un ejemplo; el límite debe salir del tiempo normal de entrega de tu entorno. Lo importante es guardar cada respuesta observada. Si una prueba escribe “temp mailid” por un error de configuración, ese texto puede aparecer en los logs como pista, pero no debe convertirse en una palabra clave del contrato.&lt;/p&gt;

&lt;p&gt;Para evitar carreras entre workers, el adaptador del inbox puede marcar el mensaje como reservado usando &lt;code&gt;event_id&lt;/code&gt;. Si dos pruebas consultan a la vez, solo una consume el enlace. La limpieza ocurre despues de capturar el resultado, no antes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo automatizar la evidencia en CI
&lt;/h2&gt;

&lt;p&gt;Cuando el flujo falla, guarda un recibo pequeño:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;event_id&lt;/code&gt;, commit y nombre del job;&lt;/li&gt;
&lt;li&gt;estado del evento y timestamps;&lt;/li&gt;
&lt;li&gt;asunto, destinatario enmascarado y message ID;&lt;/li&gt;
&lt;li&gt;cantidad de intentos y última respuesta del inbox;&lt;/li&gt;
&lt;li&gt;motivo de expiración o consumo duplicado.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No guardes el cuerpo completo si no es necesario. Una evidencia útil debe ayudar a reproducir el problema sin convertir datos de prueba en un archivo permanente. Un &lt;a href="https://dev.to/alexcarteruk/un-slo-para-emails-de-ci-en-kubernetes-22e4"&gt;SLO útil para emails de CI&lt;/a&gt; también puede separar latencia de entrega y disponibilidad del adaptador.&lt;/p&gt;

&lt;p&gt;La automatización queda mas simple cuando cada job crea su propio evento y elimina sus fixtures al terminar. Si el worker muere, el estado queda pendiente; un proceso de reconciliación puede marcarlo como expirado y dejar una razón. No conviene reintentar sin mirar el estado anterior: eso produce duplicados y hace el fallo menos visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas frecuentes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Necesito una cola para empezar?
&lt;/h3&gt;

&lt;p&gt;No siempre. Para un servicio pequeño, un &lt;code&gt;BackgroundTasks&lt;/code&gt; puede servir como primer adaptador. Cuando haya varios workers o reintentos, una cola con una clave de idempotencia será mas segura.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Por qué no basta con buscar por asunto?
&lt;/h3&gt;

&lt;p&gt;Porque dos ejecuciones pueden usar el mismo asunto. El asunto es una señal humana; &lt;code&gt;event_id&lt;/code&gt; es la identidad técnica que permite automatizar.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué debe probar el test?
&lt;/h3&gt;

&lt;p&gt;Primero que la API acepta y registra el evento. Después, en una prueba de integración, que llega el mensaje correcto, el enlace funciona una vez y un reintento no crea otra identidad. Ese reparto hace los tests mas rápidos y los fallos mucho más explicables.&lt;/p&gt;

&lt;p&gt;Un contrato de eventos no elimina la latencia de email. Lo que hace es convertir una espera opaca en una secuencia observable: solicitado, entregado, encontrado, consumido o expirado. Para un backend FastAPI, esa claridad suele valer más que añadir otro retry al azar.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>automation</category>
      <category>testing</category>
    </item>
    <item>
      <title>FastAPI: clasifica fallos de email sin reintentar a ciegas</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:23:36 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-clasifica-fallos-de-email-sin-reintentar-a-ciegas-3kjh</link>
      <guid>https://dev.to/oliviachen7/fastapi-clasifica-fallos-de-email-sin-reintentar-a-ciegas-3kjh</guid>
      <description>&lt;p&gt;En una API que envía emails, el reintento suele parecer la solución más fácil: si la entrega falla, esperamos un poco y repetimos. El problema aparece cuando tratamos igual un timeout, una dirección inválida y una bandeja temporal que todavía no ha recibido el mensaje. El sistema insiste, aumenta el ruido y al final nadie sabe qué ocurrió realmente.&lt;/p&gt;

&lt;p&gt;En proyectos con FastAPI me resulta más útil clasificar el fallo antes de decidir la siguiente acción. Así una automatización puede reintentar un problema transitorio, detenerse ante un error permanente y dejar el caso en revisión cuando falta información. Es un cambio pequeño, pero hace que las APIs sean más previsibles.&lt;/p&gt;

&lt;h2&gt;
  
  
  El error no siempre merece otro intento
&lt;/h2&gt;

&lt;p&gt;Hay tres familias que conviene separar desde el principio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transitorio:&lt;/strong&gt; timeout, conexión reiniciada o respuesta 5xx del proveedor. Puede merecer un reintento con límite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanente:&lt;/strong&gt; formato inválido, dominio inexistente o credenciales rechazadas. Repetirlo no lo arregla.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indeterminado:&lt;/strong&gt; el proveedor aceptó la solicitud, pero la respuesta se perdió. Aquí hace falta una consulta de estado o una clave de idempotencia.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El objetivo no es adivinar el futuro. Es evitar que el código convierta cualquier excepción en una orden de “hazlo otra vez”. Una función de dominio puede devolver una decisión clara: &lt;code&gt;retry&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt; o &lt;code&gt;review&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clasifica antes de reintentar
&lt;/h2&gt;

&lt;p&gt;Podemos representar la decisión con un modelo pequeño. No hace falta construir un sistema de reglas enorme para empezar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="nf"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;StrEnum&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;BaseModel&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StrEnum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="no"&gt;RETRY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"retry"&lt;/span&gt;
    &lt;span class="no"&gt;STOP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"stop"&lt;/span&gt;
    &lt;span class="no"&gt;REVIEW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"review"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NextAction&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;REVIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"límite alcanzado"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;RETRY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"fallo transitorio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;STOP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"fallo permanente"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;REVIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"respuesta no clasificada"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La regla importante está en el orden. Primero se comprueba el límite de intentos y después el tipo de respuesta. Si no, un proveedor que devuelve 503 puede generar una cadena de reintentos infinita. El backoff es sencillo a propósito; luego se le puede añadir un poco de jitter para que varios workers no vuelvan al mismo tiempo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un endpoint pequeño en FastAPI
&lt;/h2&gt;

&lt;p&gt;La API no tiene que conocer todos los detalles del proveedor. Puede recibir el resultado normalizado de un worker y exponer la decisión para que el siguiente paso sea explícito:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/deliveries/{delivery_id}/decision"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;DeliveryDecision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delivery_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# En producción, delivery_id debería resolver el estado persistido.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;decide_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En producción validaría que &lt;code&gt;delivery_id&lt;/code&gt; existe, limitaría &lt;code&gt;attempts&lt;/code&gt; recibido por el cliente y guardaría la decisión junto al evento original. El endpoint es un ejemplo del contrato, no una invitación a confiar en datos enviados sin autenticación.&lt;/p&gt;

&lt;p&gt;También ayuda separar la entrega de la observación. Un aviso de email con contexto real necesita decir qué evento falló, qué intento era y qué acción se tomó. La interfaz que consume la API no debería recibir solo “error 500”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registra la decisión, no solo la excepción
&lt;/h2&gt;

&lt;p&gt;Para depurar una automatización, guardaría al menos estos campos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;delivery_id, event_id, attempt, provider_status,
classification, next_action, retry_after, created_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No conviene registrar el contenido completo del mensaje ni tokens de acceso. Un identificador de evento y una razón corta suelen bastar. Si el test usa un &lt;code&gt;temp org mail&lt;/code&gt; o un &lt;code&gt;dummy e mail&lt;/code&gt;, la fixture debe quedar aislada del correo real y tener una caducidad conocida.&lt;/p&gt;

&lt;p&gt;En esta parte, el polling también necesita límites sanos: duración máxima, intervalo y condición de salida. Una espera que no sabe cuándo terminar es una fuga de tiempo escondida dentro del test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pruebas para automatizaciones previsibles
&lt;/h2&gt;

&lt;p&gt;Las pruebas unitarias deben cubrir la tabla de decisiones, no únicamente el camino feliz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_timeout_retries_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;RETRY&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_after_seconds&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_invalid_address_stops&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;STOP&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_max_attempts_goes_to_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide_delivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;NextAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;REVIEW&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para una prueba de integración, simularía una respuesta 503 seguida de 202 y comprobaría que solo hay dos llamadas. Después probaría un 422 y verificaría que no aparece una segunda llamada. Es facil olvidar este último caso cuando todo el entorno de pruebas responde demasiado bien.&lt;/p&gt;

&lt;p&gt;Si el flujo tiene una interfaz web, vale la pena conectar el estado backend con mensajes comprensibles. Un estado “revisar” es mejor que un spinner eterno; este tipo de detalle también aparece al diseñar &lt;a href="https://dev.to/silviutech/react-avisos-de-email-con-contexto-real-4igo"&gt;avisos de email con contexto real&lt;/a&gt;. Para comprobar la bandeja, puedes aplicar &lt;a href="https://dev.to/silviutech/fastapi-polling-de-inbox-con-limites-sanos-3ga8-temp-slug-3929817?preview=467d18814025d80aabaaa416c1c97811653741a49975f92f37128cecc28ad7f30fdad489994315352f1fcc355c01b1965f42a7bf77a66c63c703426e"&gt;polling de inbox con límites sanos&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A: dudas habituales
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Debo reintentar todos los errores 5xx?
&lt;/h3&gt;

&lt;p&gt;No necesariamente. Un 5xx suele ser transitorio, pero el proveedor puede estar rechazando una operación por saturación o por una cuota. Usa un máximo de intentos, backoff y una ruta de revisión. Si existe un encabezado &lt;code&gt;Retry-After&lt;/code&gt;, úsalo dentro de un límite razonable.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué hago si no recibo respuesta?
&lt;/h3&gt;

&lt;p&gt;Trátalo como indeterminado. Antes de enviar otra vez, consulta el estado con un identificador idempotente. Duplicar un email porque se perdió la respuesta es peor que tardar unos segundos en confirmar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist de implementación
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Define las categorías transitorio, permanente e indeterminado.&lt;/li&gt;
&lt;li&gt;Pon un límite de intentos fuera del proveedor.&lt;/li&gt;
&lt;li&gt;Devuelve una acción explícita: reintentar, detener o revisar.&lt;/li&gt;
&lt;li&gt;Registra evento, intento y decisión sin guardar datos innecesarios.&lt;/li&gt;
&lt;li&gt;Añade backoff y, cuando aplique, &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prueba una secuencia 5xx → éxito y otra con error permanente.&lt;/li&gt;
&lt;li&gt;Establece un timeout para cada polling y para el flujo completo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La automatización de email no necesita más reintentos, sino mejores decisiones. Cuando FastAPI expone un contrato pequeño y cada fallo deja evidencia suficiente, los workers pueden recuperarse sin ocultar los casos que necesitan atención humana.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>api</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: un contrato de expiración para emails de prueba</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Tue, 10 Nov 2026 20:23:58 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-un-contrato-de-expiracion-para-emails-de-prueba-fi5</link>
      <guid>https://dev.to/oliviachen7/fastapi-un-contrato-de-expiracion-para-emails-de-prueba-fi5</guid>
      <description>&lt;p&gt;En una prueba de registro o recuperación de contraseña, crear un email temporal suele parecer la parte fácil. El problema aparece después: el buzón queda vivo, otro test lo reutiliza y nadie sabe cuándo se puede borrar. En CI, esa pequeña ambigüedad termina en pruebas intermitentes y datos que se acumulan.&lt;/p&gt;

&lt;p&gt;Una solución práctica es tratar cada buzón de prueba como un recurso con contrato de expiración. No hace falta construir una plataforma enorme. Con FastAPI, un identificador de ejecución, una fecha límite y una tarea de limpieza se puede hacer el comportamiento mucho más predecible.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema: un buzón de prueba que nunca muere
&lt;/h2&gt;

&lt;p&gt;Un flujo típico hace esto:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Crea una dirección temporal.&lt;/li&gt;
&lt;li&gt;La entrega al navegador o al cliente API.&lt;/li&gt;
&lt;li&gt;Espera el mensaje de confirmación.&lt;/li&gt;
&lt;li&gt;Comprueba el enlace.&lt;/li&gt;
&lt;li&gt;Abandona el recurso.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El quinto paso es el que suele faltar. Si el test pasa, nadie se acuerda del buzón. Si falla, el diagnóstico puede dejar varios recursos abiertos. Y si dos ejecuciones usan el mismo identificador, una limpieza puede borrar los datos que todavía necesita la otra.&lt;/p&gt;

&lt;p&gt;También aparecen entradas poco útiles en los logs cuando alguien escribe &lt;code&gt;tempail mail&lt;/code&gt; o &lt;code&gt;tepm mail com&lt;/code&gt; y termina probando otra configuración. Parece pequeño, pero después cuesta saber qué dato pertenece a cada corrida.&lt;/p&gt;

&lt;p&gt;Conviene separar tres conceptos: propietario, caducidad y estado. El propietario identifica la ejecución que creó el recurso; la caducidad dice hasta cuándo es válido; el estado distingue entre activo, consumido y expirado. Esta separación también ayuda a &lt;a href="https://dev.to/silviutech/fastapi-aisla-pruebas-de-email-por-entorno-3e2l"&gt;aislar las pruebas de email por entorno&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué debe prometer el contrato
&lt;/h2&gt;

&lt;p&gt;Antes de escribir endpoints, define promesas pequeñas y verificables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cada recurso pertenece a un &lt;code&gt;run_id&lt;/code&gt; único.&lt;/li&gt;
&lt;li&gt;La fecha &lt;code&gt;expires_at&lt;/code&gt; se calcula en el servidor, no en el navegador.&lt;/li&gt;
&lt;li&gt;Leer mensajes después de expirar devuelve un error claro.&lt;/li&gt;
&lt;li&gt;Repetir la operación de expiración no rompe nada.&lt;/li&gt;
&lt;li&gt;La limpieza no toca recursos de otra ejecución.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No es un sistema de correo de producción. Es una frontera de datos para testing. Por eso el contrato debe ser sencillo, observable y fácil de borrar. Para flujos más automáticos, también merece la pena pensar en &lt;a href="https://dev.to/silviutech/agentes-llm-limites-seguros-para-el-email"&gt;límites seguros para automatizar email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Los nombres de estado importa más de lo que parecen: si son vagos, cada cliente interpreta el ciclo de vida a su manera. Al principio parece un poco más trabajo, pero ahorra conversaciones raras en el equipo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un modelo pequeño en FastAPI
&lt;/h2&gt;

&lt;p&gt;Este ejemplo usa memoria para mostrar la idea. En un servicio real, la misma información iría a PostgreSQL o Redis, con un índice por &lt;code&gt;run_id&lt;/code&gt; y &lt;code&gt;expires_at&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid4&lt;/span&gt;

&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mailboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;TTL_MINUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/test-mailboxes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_mailbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mailbox_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="no"&gt;TTL_MINUTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mailboxes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mailbox_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s2"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mailbox_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="nd"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/test-mailboxes/{mailbox_id}/messages"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mailbox_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mailbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mailboxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mailbox_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"mailbox not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"expired"&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"mailbox expired"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El código usa UTC y responde &lt;code&gt;410 Gone&lt;/code&gt; cuando el recurso ya no debe utilizarse. Ese detalle es más útil que devolver una lista vacía: el test sabe si debe reintentar la espera o crear un buzón nuevo. Un detalle que se olvida facil es no mezclar &lt;code&gt;datetime&lt;/code&gt; sin zona horaria con valores UTC; tarde o temprano produce comparaciones raras.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expirar sin carreras ni borrados peligrosos
&lt;/h2&gt;

&lt;p&gt;La expiración no debería depender de que un test llame a un endpoint final. Añade un trabajo periódico que busque recursos vencidos y los marque o borre. Pero hazlo con una condición que incluya la fecha límite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;test_mailboxes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="kc"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'consumed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si una ejecución todavía está leyendo un mensaje, puedes preferir una fase de gracia: primero marcas &lt;code&gt;expired&lt;/code&gt;, esperas unos segundos y después borras. En Redis, un TTL puede servir para el almacenamiento, pero conserva también &lt;code&gt;run_id&lt;/code&gt; en los datos para poder auditar qué pasó.&lt;/p&gt;

&lt;p&gt;La limpieza ocurren después de la expiración, no antes de que venza el plazo. Esa diferencia evita que un job lento pierda su evidencia justo cuando más la necesita.&lt;/p&gt;

&lt;p&gt;La operación debe ser idempotente. Ejecutar el limpiador una vez o cinco veces debe dejar el mismo resultado. También conviene registrar &lt;code&gt;mailbox_id&lt;/code&gt;, &lt;code&gt;run_id&lt;/code&gt;, motivo y duración de vida; esos cuatro campos dan una pista bastante buena cuando el CI falla de noche.&lt;/p&gt;

&lt;p&gt;Evita renovar el TTL en cada lectura. Si el test está bloqueado esperando un mensaje, podría mantener el recurso vivo para siempre. El contrato debe tener una fecha máxima que solo un endpoint explícito y autorizado pueda extender, si de verdad hace falta.&lt;/p&gt;

&lt;p&gt;Si el consumidor se queda esperando por mucho tiempo, devuelve un estado intermedio y registra el intento; no lo dejes esperando para siempre. Es una regla sencilla, pero suele olvidarse facil cuando el flujo feliz funciona.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A rápido
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Por qué no devolver siempre una lista vacía?
&lt;/h3&gt;

&lt;p&gt;Porque una lista vacía puede significar “el email todavía no llegó” o “el buzón ya no existe”. Diferenciar &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;404&lt;/code&gt; y &lt;code&gt;410&lt;/code&gt; hace que el cliente tome decisiones más sanas.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Es suficiente un &lt;code&gt;run_id&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Es una buena base, pero debe ser impredecible entre ejecuciones y no contener datos personales. Añade un índice y comprueba la propiedad antes de leer o borrar.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué TTL conviene usar?
&lt;/h3&gt;

&lt;p&gt;Empieza con el tiempo máximo real de la prueba más un margen pequeño, por ejemplo 15 minutos para un flujo que normalmente tarda dos. Mídelo después: un TTL gigante solo es una forma lenta de esconder fugas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para llevarlo a CI
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Genera un &lt;code&gt;run_id&lt;/code&gt; nuevo por job y por intento.&lt;/li&gt;
&lt;li&gt;Guarda &lt;code&gt;expires_at&lt;/code&gt; en UTC y devuelve la fecha al cliente.&lt;/li&gt;
&lt;li&gt;Devuelve &lt;code&gt;410&lt;/code&gt; para un buzón vencido.&lt;/li&gt;
&lt;li&gt;Ejecuta una limpieza periódica y otra al terminar el job.&lt;/li&gt;
&lt;li&gt;No reutilices direcciones entre pruebas paralelas.&lt;/li&gt;
&lt;li&gt;Prueba la doble expiración y la lectura durante la limpieza.&lt;/li&gt;
&lt;li&gt;Conserva métricas de recursos activos, vencidos y borrados.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El objetivo no es simular cada detalle de un proveedor de correo. Es hacer explícito el ciclo de vida del dato temporal. Cuando FastAPI expone esa frontera con estados claros, los tests dejan de depender de suerte y la automatización se vuelve un poco más tranquila, incluso cuando algo falla.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: un reloj para emails de prueba</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Sun, 08 Nov 2026 17:23:43 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-un-reloj-para-emails-de-prueba-4n4e</link>
      <guid>https://dev.to/oliviachen7/fastapi-un-reloj-para-emails-de-prueba-4n4e</guid>
      <description>&lt;h1&gt;
  
  
  FastAPI: un reloj para emails de prueba
&lt;/h1&gt;

&lt;p&gt;Los flujos de registro y recuperación de contraseña suelen fallar por una razón poco visible: nadie ha definido cuanto tiempo debe esperar el sistema por un email. En local, un correo llega enseguida. En CI, la misma prueba puede quedarse esperando, reintentando y dejando datos dificiles de interpretar.&lt;/p&gt;

&lt;p&gt;Un correo electrónico desechable o un generador de cuentas de correo temporal puede ayudar a aislar cada ejecución, pero no resuelve por si solo el problema del tiempo. En este artículo propongo un patrón pequeño con FastAPI: tratar el reloj como parte del contrato de la automatización.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué el tiempo es parte del contrato del email
&lt;/h2&gt;

&lt;p&gt;Una prueba de verificación no solo necesita una dirección. Necesita saber:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cuándo se creó el buzón temporal;&lt;/li&gt;
&lt;li&gt;cuánto tiempo puede esperar el primer mensaje;&lt;/li&gt;
&lt;li&gt;cuántos reintentos están permitidos;&lt;/li&gt;
&lt;li&gt;qué evidencia se guarda cuando no llega nada.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sin estos límites, un &lt;code&gt;sleep(10)&lt;/code&gt; termina siendo una política escondida. A veces es demasiado corto y produce un falso fallo; otras veces es demasiado largo y hace lenta toda la suite. El resultado parece aleatorio, aunque el problema real es que la prueba no tiene un deadline explicito.&lt;/p&gt;

&lt;p&gt;La automatización gana claridad cuando cada ejecución tiene un &lt;code&gt;run_id&lt;/code&gt;, un límite de tiempo y un estado final. Así es mas fácil distinguir entre “el proveedor tardó”, “el webhook llegó tarde” y “la aplicación nunca solicitó el mensaje”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un modelo pequeño de deadline en FastAPI
&lt;/h2&gt;

&lt;p&gt;Podemos representar el contrato con un modelo de Pydantic y calcular el tiempo restante antes de cada espera. El ejemplo no depende de un proveedor concreto; la función &lt;code&gt;read_inbox&lt;/code&gt; puede ser un cliente HTTP interno o un adaptador de pruebas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailLease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
    &lt;span class="n"&gt;deadline_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deadline_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;remaining_seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El endpoint puede recibir una solicitud de lectura y devolver un estado explícito. No conviene bloquear una petición web durante toda la espera; para una suite de integración, un worker o una tarea de fondo suele ser mejor. Aun así, el mismo contrato sirve para ambos caminos.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/runs/{run_id}/email-status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;email_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load_lease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id desconocido&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remaining_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expired&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;read_inbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waiting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;received&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En producción, &lt;code&gt;load_lease&lt;/code&gt; y &lt;code&gt;read_inbox&lt;/code&gt; necesitan límites propios y manejo de errores. El punto importante es que nunca deben esperar más que el tiempo que queda en el contrato.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separar espera, reintentos y evidencia
&lt;/h2&gt;

&lt;p&gt;Un timeout no debe decidir por accidente cuánto reintentar. Son tres decisiones distintas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Espera:&lt;/strong&gt; cuánto dura una lectura individual, por ejemplo cinco segundos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reintento:&lt;/strong&gt; cuántas lecturas se permiten antes del fallo, por ejemplo nueve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidencia:&lt;/strong&gt; qué se registra en cada intento: &lt;code&gt;run_id&lt;/code&gt;, número de intento, tiempo restante y resultado.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Para depurar, guardo un evento compacto por intento. No guardo el contenido completo del correo si no hace falta. Esta separación hace mas simple comparar dos ejecuciones y evita que un log lleno de ruido esconda el fallo.&lt;/p&gt;

&lt;p&gt;También conviene usar un idempotency key al pedir el mensaje de prueba. Si el worker se reinicia, no debería crear tres buzones por el mismo escenario. Un nombre como &lt;code&gt;signup:{run_id}&lt;/code&gt; permite localizar los datos y limpiarlos después.&lt;/p&gt;

&lt;p&gt;Si el equipo busca una implementación de colas, este enfoque combina bien con unas &lt;a href="https://dev.to/silviutech/fastapi-colas-de-email-con-trazas-utiles-1fc6"&gt;colas de email con trazas útiles&lt;/a&gt;. Y cuando una ejecución sí termina en incidente, los &lt;a href="https://dev.to/alexcarteruk/sre-correos-de-incidentes-sin-ruido-operativo-5bc6"&gt;correos de incidentes sin ruido operativo&lt;/a&gt; ofrecen una buena referencia para decidir qué contexto conservar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo probar el reloj sin pruebas lentas
&lt;/h2&gt;

&lt;p&gt;No hace falta esperar 45 segundos en cada test. Inyecta un reloj y un lector de bandeja falsos. Para una prueba, el reloj puede empezar en &lt;code&gt;12:00:00&lt;/code&gt;; el primer intento devuelve &lt;code&gt;None&lt;/code&gt;, el segundo también y el tercero devuelve un mensaje. Otra prueba avanza el reloj más allá del deadline y verifica &lt;code&gt;expired&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;El test debe comprobar el comportamiento, no los segundos exactos de una máquina compartida. Un esquema útil es:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;crea una lease con un deadline de 10 segundos;&lt;/li&gt;
&lt;li&gt;simula dos lecturas sin mensaje;&lt;/li&gt;
&lt;li&gt;devuelve un mensaje con el tercer intento;&lt;/li&gt;
&lt;li&gt;verifica que el estado final es &lt;code&gt;received&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;verifica que cada lectura recibió un timeout menor o igual al tiempo restante.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para documentación y datos de ejemplo, algunas personas escriben nombres como &lt;code&gt;dummy e mail&lt;/code&gt; o &lt;code&gt;temp gamil com&lt;/code&gt;. Pueden servir para probar búsqueda y validación, pero mantenlos como texto de prueba, nunca como direcciones reales ni como una regla de negocio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para automatización estable
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Cada ejecución tiene un &lt;code&gt;run_id&lt;/code&gt; único.&lt;/li&gt;
&lt;li&gt;[ ] La lease guarda creación y expiración.&lt;/li&gt;
&lt;li&gt;[ ] El timeout de lectura respeta el tiempo restante.&lt;/li&gt;
&lt;li&gt;[ ] Los reintentos tienen un máximo explícito.&lt;/li&gt;
&lt;li&gt;[ ] Los eventos registran resultado y contexto mínimo.&lt;/li&gt;
&lt;li&gt;[ ] Los tests usan reloj y lector inyectables.&lt;/li&gt;
&lt;li&gt;[ ] Un proceso de limpieza elimina buzones y mensajes expirados.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Este patrón es pequeño, pero cambia la conversación del equipo. En lugar de preguntar “¿por qué a veces falla el email?”, podemos preguntar “¿en qué estado terminó la lease y cuánto tiempo quedaba?”. Esa pregunta suele llevar mas rápido a la causa.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas frecuentes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Debo hacer polling o usar webhooks?
&lt;/h3&gt;

&lt;p&gt;Usa webhooks si el proveedor y tu entorno los soportan de forma fiable. Mantén un polling acotado como respaldo para pruebas locales o servicios que no tienen eventos. En ambos casos, el deadline debe ser único y visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Un buzón temporal sustituye las pruebas de email reales?
&lt;/h3&gt;

&lt;p&gt;No. Es útil para flujos de integración, aislamiento y datos efímeros. Las pruebas de entrega, reputación y compatibilidad de plantillas necesitan otros entornos y destinatarios controlados.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué métrica empezaría a medir?
&lt;/h3&gt;

&lt;p&gt;Mide el tiempo desde la solicitud hasta la recepción, la tasa de expiración y el número de reintentos. Separar esos valores revela si el cuello de botella está en FastAPI, en la cola o en el proveedor de correo.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>automation</category>
      <category>testing</category>
    </item>
    <item>
      <title>FastAPI: eventos de email sin polling ciego</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Sat, 07 Nov 2026 14:23:32 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-eventos-de-email-sin-polling-ciego-3kmc</link>
      <guid>https://dev.to/oliviachen7/fastapi-eventos-de-email-sin-polling-ciego-3kmc</guid>
      <description>&lt;p&gt;En un flujo de signup, el email suele parecer una tarea secundaria: crear la cuenta, enviar un enlace y esperar que aparezca en una bandeja de prueba. El problema llega cuando el test pregunta cada pocos segundos si ya hay un mensaje nuevo. Ese polling funciona al principio, pero pronto se vuelve lento, frágil y dificil de explicar.&lt;/p&gt;

&lt;p&gt;He visto el mismo fallo en proyectos con Python: el endpoint responde bien, el worker envia el mensaje, pero la prueba mira la bandeja demasiado pronto. Luego se añade un &lt;code&gt;sleep(3)&lt;/code&gt;, despues otro reintento y, finalmente, un test que pasa en local pero falla en CI. No es que el correo sea misterioso. Falta un contrato claro entre el evento que produce el backend y la evidencia que consume la prueba.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema de esperar una bandeja
&lt;/h2&gt;

&lt;p&gt;Un polling ciego mezcla varias preguntas distintas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;¿Se creó la cuenta?&lt;/li&gt;
&lt;li&gt;¿Se generó el evento de verificación?&lt;/li&gt;
&lt;li&gt;¿El proveedor aceptó el mensaje?&lt;/li&gt;
&lt;li&gt;¿La bandeja de prueba lo recibió?&lt;/li&gt;
&lt;li&gt;¿El test está leyendo el mensaje correcto?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cuando todas se convierten en “espera hasta que aparezca un email”, cualquier retraso parece un fallo de aplicación. Ademas, si se reutiliza la misma bandeja entre tests paralelos, un mensaje viejo puede hacer que el caso equivocado pase.&lt;/p&gt;

&lt;p&gt;Una solución sencilla es tratar el email como un evento observable. El endpoint no necesita esperar a que llegue el mensaje. Solo debe devolver una identidad de operación y registrar qué evento se produjo. La bandeja puede seguir siendo útil, pero ya no es la única fuente de verdad.&lt;/p&gt;

&lt;p&gt;En un proyecto reciente, esta separación me resultó más clara junto con una guía para &lt;a href="https://dev.to/silviutech/fastapi-separa-emails-de-signup-por-escenario-4ncb"&gt;separar emails de signup por escenario&lt;/a&gt;. Cada escenario tiene su propio destino, y el evento lleva el contexto suficiente para no confundir registros.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un contrato de eventos pequeño
&lt;/h2&gt;

&lt;p&gt;No hace falta empezar con una plataforma de mensajería grande. Un contrato mínimo puede tener estos campos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_7f21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verification_email_requested"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acc_204"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inbox_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test-inbox-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-07T14:20:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"queued"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La combinación de &lt;code&gt;event_id&lt;/code&gt; e &lt;code&gt;inbox_id&lt;/code&gt; es importante. El primero permite deduplicar. El segundo mantiene aislado el correo de un test respecto a los demás. Si usamos un generador de correo temporal para crear el destino, la aplicación debe guardar solo la referencia necesaria para ese caso, no copiar mensajes enteros a cada log.&lt;/p&gt;

&lt;p&gt;Los estados pueden ser pocos: &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;delivered&lt;/code&gt; y &lt;code&gt;failed&lt;/code&gt;. No intentaria representar todas las situaciones del proveedor. El objetivo es que una prueba pueda saber si está esperando un evento que todavía no existe o una entrega que ya falló.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un endpoint de webhook en FastAPI
&lt;/h2&gt;

&lt;p&gt;El endpoint que recibe una confirmación del proveedor debe ser idempotente. Si llega dos veces el mismo evento, la segunda petición no debe crear una entrega nueva ni cambiar un estado final a otro más confuso.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhooks/email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_email_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;save_event_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En código real, &lt;code&gt;load_event&lt;/code&gt; y &lt;code&gt;save_event_status&lt;/code&gt; estarían detrás de una transacción o una operación condicional. La idea importante es que el evento tenga una clave estable y que la transición sea explícita. Si el proveedor reintenta el webhook, nuestra API responde igual sin duplicar trabajo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo probar sin carreras
&lt;/h2&gt;

&lt;p&gt;Para un test de signup, prefiero este orden:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Crear un &lt;code&gt;inbox_id&lt;/code&gt; único para el caso.&lt;/li&gt;
&lt;li&gt;Crear la cuenta con ese identificador en el contexto de prueba.&lt;/li&gt;
&lt;li&gt;Esperar el evento &lt;code&gt;verification_email_requested&lt;/code&gt;, no tres segundos arbitrarios.&lt;/li&gt;
&lt;li&gt;Consultar solo mensajes posteriores a &lt;code&gt;created_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Confirmar que el mensaje pertenece al &lt;code&gt;event_id&lt;/code&gt; y al &lt;code&gt;inbox_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Consumir el enlace y guardar la evidencia mínima.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Si el sistema todavía no tiene eventos consultables, se puede envolver el polling en un helper que tenga un límite, una hora de inicio y una razón visible para cada reintento. Eso es mucho mejor que un &lt;code&gt;while True&lt;/code&gt; con una pausa fija. Cuando el test falla, el log debe decir si no hubo evento, si hubo un evento sin mensaje o si el mensaje llegó pero no coincidía.&lt;/p&gt;

&lt;p&gt;También conviene distinguir un timeout de infraestructura de un rechazo funcional. Un proveedor lento no significa que el token sea invalido. Esta distinción parece pequeña, pero reduce bastante el tiempo de diagnostico.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para el siguiente flujo
&lt;/h2&gt;

&lt;p&gt;Antes de publicar otro endpoint de verificación, revisaria esto:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;¿Cada ejecución tiene un &lt;code&gt;event_id&lt;/code&gt; y una bandeja propia?&lt;/li&gt;
&lt;li&gt;¿El endpoint puede recibir el mismo webhook dos veces?&lt;/li&gt;
&lt;li&gt;¿Los estados terminales se protegen contra cambios accidentales?&lt;/li&gt;
&lt;li&gt;¿El test espera una condición observable en vez de un tiempo fijo?&lt;/li&gt;
&lt;li&gt;¿Los logs omiten el contenido sensible del mensaje?&lt;/li&gt;
&lt;li&gt;¿Se conserva suficiente evidencia para explicar el fallo?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Un compañero puede escribir &lt;code&gt;tempail&lt;/code&gt; en una búsqueda o en una nota de QA mientras intenta localizar una herramienta. No pasa nada, pero esa pista no debe convertirse en una regla de negocio ni en una dirección compartida por todos los tests.&lt;/p&gt;

&lt;p&gt;La meta no es eliminar por completo el polling. En algunos entornos sigue siendo la integración disponible. La meta es darle un contexto: identidad, límite, evento esperado y motivo de salida. Con ese pequeño contrato, FastAPI queda más fácil de probar, los reintentos dejan mejor evidencia y el equipo puede corregir el punto real del flujo, no solo aumentar el &lt;code&gt;sleep&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>automation</category>
      <category>backend</category>
    </item>
    <item>
      <title>FastAPI: eventos de email sin polling frágil</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Tue, 03 Nov 2026 23:23:32 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-eventos-de-email-sin-polling-fragil-2874</link>
      <guid>https://dev.to/oliviachen7/fastapi-eventos-de-email-sin-polling-fragil-2874</guid>
      <description>&lt;p&gt;Los tests de email suelen empezar con una solución sencilla: enviar el mensaje, hacer &lt;code&gt;sleep(5)&lt;/code&gt; y buscar el correo más reciente. Funciona en local, pero en CI se vuelve lento y bastante fragil. Un proveedor tarda más, dos jobs comparten inbox o el test encuentra un mensaje viejo.&lt;/p&gt;

&lt;p&gt;Una alternativa práctica es tratar cada email de prueba como un evento correlacionado. La aplicación publica una señal con un identificador de ejecución y el lector busca exactamente ese evento, dentro de una ventana de tiempo. No elimina los retrasos de red, pero evita que el test adivine qué mensaje le pertenece.&lt;/p&gt;

&lt;h2&gt;
  
  
  El síntoma: un test que espera a ciegas
&lt;/h2&gt;

&lt;p&gt;El polling no es malo por sí mismo. El problema aparece cuando es la única forma de saber que algo paso. Un flujo típico tiene estas decisiones escondidas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cuánto tiempo esperar antes del primer intento;&lt;/li&gt;
&lt;li&gt;qué ocurre si hay dos mensajes con el mismo asunto;&lt;/li&gt;
&lt;li&gt;cómo distinguir un email de esta ejecución de uno anterior;&lt;/li&gt;
&lt;li&gt;quién guarda la evidencia cuando el timeout expira.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cuando esas reglas no están en un contrato, cada helper las resuelve de una forma distinta. En una búsqueda de documentación puede aparecer incluso &lt;code&gt;tempail mail&lt;/code&gt; o &lt;code&gt;tamp mail com&lt;/code&gt;, pero el nombre que se usa no arregla la falta de correlación.&lt;/p&gt;

&lt;p&gt;El objetivo no es convertir un email en un sistema distribuido enorme. Es adjuntar contexto mínimo: &lt;code&gt;run_id&lt;/code&gt;, destinatario, tipo de evento, momento de emisión y estado de consumo.&lt;/p&gt;

&lt;h2&gt;
  
  
  De polling a eventos correlacionados
&lt;/h2&gt;

&lt;p&gt;Imagina que el backend genera un evento cuando crea el email de verificación. Ese evento no tiene que contener el cuerpo completo del mensaje. Puede incluir un identificador, un hash del contenido y la referencia al destinatario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_8f2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ci_2026_11_04_001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verification_email.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recipient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test-42@example.invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-04T00:15:20Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El test espera una señal con &lt;code&gt;run_id=ci_2026_11_04_001&lt;/code&gt;. Después puede consultar el inbox para confirmar el enlace, pero ya no necesita decidir cuál de cinco mensajes recientes es el correcto.&lt;/p&gt;

&lt;p&gt;Este patrón también mejora el diagnóstico. Si el evento existe pero el mensaje no llega, el problema está entre la cola y el proveedor. Si no existe el evento, hay que revisar la aplicación. La frontera queda bastante más clara.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un contrato pequeño en FastAPI
&lt;/h2&gt;

&lt;p&gt;FastAPI puede exponer un endpoint interno para registrar y consultar eventos de prueba. En producción conviene usar PostgreSQL o una cola real; este ejemplo se concentra en el contrato HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verification_email.created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/test-events/email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/test-events/email/{run_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Evento no encontrado&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El endpoint devuelve &lt;code&gt;duplicate=true&lt;/code&gt; para un reintento del mismo &lt;code&gt;event_id&lt;/code&gt;. Esa respuesta es útil para un runner: repetir una llamada no crea una segunda señal que pueda confundir al lector.&lt;/p&gt;

&lt;p&gt;Si el proyecto todavía usa polling, se puede introducir este contrato poco a poco. Primero registra el evento, luego conserva el polling como fallback y finalmente mide cuántos tests siguen necesitándolo. La transición es mas segura cuando cada paso deja métricas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotencia y reintentos
&lt;/h2&gt;

&lt;p&gt;El evento debe ser idempotente, pero también lo debe ser el consumidor. Una secuencia razonable es:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;crear un &lt;code&gt;run_id&lt;/code&gt; único para el caso;&lt;/li&gt;
&lt;li&gt;registrar el evento de emisión;&lt;/li&gt;
&lt;li&gt;esperar la señal con un timeout explícito;&lt;/li&gt;
&lt;li&gt;buscar el mensaje usando &lt;code&gt;run_id&lt;/code&gt;, destinatario y fecha;&lt;/li&gt;
&lt;li&gt;marcar el evento como consumido una sola vez;&lt;/li&gt;
&lt;li&gt;guardar un resumen y limpiar los datos temporales.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No marques el evento como consumido antes de validar el enlace. Si el parser falla y el test reintenta, perderías una pista importante. También conviene registrar razones distintas para &lt;code&gt;event_not_found&lt;/code&gt;, &lt;code&gt;message_not_found&lt;/code&gt; y &lt;code&gt;invalid_message&lt;/code&gt;; “email timeout” dice muy poco.&lt;/p&gt;

&lt;p&gt;Para operar el flujo, las alertas de &lt;a href="https://dev.to/alexcarteruk/kubernetes-alertas-de-rollback-con-contexto-real-2em2"&gt;rollback con contexto real&lt;/a&gt; ofrecen una idea útil: una señal debe incluir contexto accionable, no solo un contador rojo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo llevarlo a CI
&lt;/h2&gt;

&lt;p&gt;Antes de abrir el navegador o llamar al endpoint de registro, el job puede preparar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;un &lt;code&gt;run_id&lt;/code&gt; que incluya el número de intento;&lt;/li&gt;
&lt;li&gt;un artefacto JSON con el evento y sus timestamps;&lt;/li&gt;
&lt;li&gt;un timeout total y un intervalo de polling definidos en configuración;&lt;/li&gt;
&lt;li&gt;una política para conservar solo el resumen, no todo el contenido sensible;&lt;/li&gt;
&lt;li&gt;un paso &lt;code&gt;finally&lt;/code&gt; para liberar la bandeja o borrar el evento.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;También ayuda &lt;a href="https://dev.to/silviutech/fastapi-aisla-pruebas-de-email-por-entorno-3e2l"&gt;aislar pruebas de email por entorno&lt;/a&gt;, porque el mismo contrato puede aplicarse a local, staging y CI sin mezclar bandejas. Si un job falla, guarda el &lt;code&gt;run_id&lt;/code&gt; en el mensaje del error. Parece un detalle menor, pero ahorra bastante tiempo al revisar logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas rápidas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Esto elimina el polling?
&lt;/h3&gt;

&lt;p&gt;No necesariamente. El evento indica qué esperar; el inbox todavía puede requerir polling si el proveedor no ofrece webhook. La diferencia es que el polling tiene una identidad y un límite.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Debo guardar el email completo?
&lt;/h3&gt;

&lt;p&gt;Solo cuando haga falta para depurar. Un hash, el asunto, el remitente y los timestamps suelen ser suficientes para la mayoría de los fallos.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué pasa si dos tests usan el mismo destinatario?
&lt;/h3&gt;

&lt;p&gt;El &lt;code&gt;run_id&lt;/code&gt; debe seguir siendo distinto y el filtro debe comprobar también el tiempo de emisión. Si el proveedor permite direcciones aisladas, usarlas reduce todavía más la ambigüedad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;Un evento correlacionado convierte “espera hasta que llegue algo” en “espera este mensaje de esta ejecución”. Con FastAPI, un endpoint pequeño, una clave idempotente y razones de fallo explícitas bastan para dar el primer paso.&lt;/p&gt;

&lt;p&gt;El resultado no es solo un pipeline un poco mas rápido. Es una prueba que explica qué esperaba, qué recibió y en qué frontera se perdió la señal.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: un lease para emails de verificación</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Tue, 03 Nov 2026 05:23:01 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-un-lease-para-emails-de-verificacion-1bag</link>
      <guid>https://dev.to/oliviachen7/fastapi-un-lease-para-emails-de-verificacion-1bag</guid>
      <description>&lt;p&gt;Un email de verificación puede romper un test aunque el endpoint funcione bien. El mensaje llega tarde, el polling encuentra una copia vieja o dos ejecuciones leen la misma bandeja. Luego el equipo repite el pipeline y la causa queda medio escondida.&lt;/p&gt;

&lt;p&gt;Una forma práctica de ordenar este problema es tratar la bandeja de prueba como un recurso alquilado: cada ejecución obtiene un &lt;strong&gt;lease&lt;/strong&gt;, una identidad, una hora de comienzo y una fecha de expiración. El test solo puede leer mensajes que pertenecen a ese periodo. No es una solución magica, pero convierte un fallo confuso en un contrato que se puede inspeccionar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema: una bandeja que nadie posee
&lt;/h2&gt;

&lt;p&gt;En un flujo de registro, la aplicación suele enviar un enlace a un correo temporal para pruebas. Si todos los tests usan la misma dirección, aparecen tres riesgos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;una ejecución consume el mensaje de otra;&lt;/li&gt;
&lt;li&gt;el filtro por asunto elige un email antiguo;&lt;/li&gt;
&lt;li&gt;un reintento parece pasar, pero nadie sabe que uso datos de la primera ejecución.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;He visto este patrón en automatizaciones pequeñas: primero se añade un &lt;code&gt;sleep(5)&lt;/code&gt;, despues otro reintento y finalmente un timeout más largo. El pipeline tarda más, pero no es mas confiable. Incluso búsquedas como &lt;code&gt;temp gamil com&lt;/code&gt; o &lt;code&gt;fake e mail com&lt;/code&gt; en notas de QA suelen mostrar que la convención de datos no está clara.&lt;/p&gt;

&lt;p&gt;La idea del lease es sencilla: el sistema que prepara el test crea una reserva con un identificador único. Ese identificador acompaña la petición de verificación y también queda guardado junto al mensaje esperado.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué debe representar un lease
&lt;/h2&gt;

&lt;p&gt;Un lease útil necesita pocos campos, pero tienen que ser explícitos:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;lease_id&lt;/code&gt;: UUID de la ejecución o del caso de prueba.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mailbox&lt;/code&gt;: dirección aislada para recibir el mensaje.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;created_at&lt;/code&gt;: límite inferior para ignorar mensajes viejos.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expires_at&lt;/code&gt;: límite superior para no dejar polling infinito.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt;: activo, consumido o expirado.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Con esto, el test deja de preguntar “¿cuál es el último email?” y pregunta “¿cuál es el mensaje de mi lease, creado después de mi acción?”. Esa diferencia parece pequeña, pero hace el diagnóstico bastante mas facil.&lt;/p&gt;

&lt;p&gt;También conviene que el lease sea idempotente. Si el runner repite la llamada con el mismo &lt;code&gt;lease_id&lt;/code&gt;, la API debe devolver la reserva existente, no crear otra bandeja que después nadie limpia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un contrato pequeño en FastAPI
&lt;/h2&gt;

&lt;p&gt;Este ejemplo solo modela la parte de control. El proveedor real de correo se puede conectar detrás de &lt;code&gt;MailboxService&lt;/code&gt; sin mezclarlo con las rutas HTTP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid4&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;leases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LeaseRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ttl_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/test-email/leases&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_lease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LeaseRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lease_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;lease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lease_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lease_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ttl_seconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;leases&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lease_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/test-email/leases/{lease_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_lease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;leases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lease no encontrado&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En producción, el diccionario debe ser una tabla o un almacén con expiración. El ejemplo sirve para mostrar el límite: la API es dueña del estado del lease y el lector de emails no necesita adivinarlo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polling con límites y liberación
&lt;/h2&gt;

&lt;p&gt;El consumidor puede enviar &lt;code&gt;lease_id&lt;/code&gt; al servicio que consulta la bandeja. El servicio aplica estos filtros en este orden:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;comprobar que el lease existe y sigue activo;&lt;/li&gt;
&lt;li&gt;descartar mensajes con fecha anterior a &lt;code&gt;created_at&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;comprobar que el destinatario y el asunto corresponden al caso;&lt;/li&gt;
&lt;li&gt;marcar el mensaje como consumido después de validarlo;&lt;/li&gt;
&lt;li&gt;liberar la bandeja al terminar, también si el test falla.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La última parte se olvida bastante. En Python, una función de cleanup llamada desde &lt;code&gt;finally&lt;/code&gt; suele ser suficiente para el runner. Si el proveedor permite una clave de correlación, úsala junto al &lt;code&gt;lease_id&lt;/code&gt;; el asunto por sí solo es demasiado debil.&lt;/p&gt;

&lt;p&gt;El timeout debe pertenecer al contrato, no estar repartido entre tres helpers. Por ejemplo, un lease de 180 segundos puede tener polling cada dos segundos, pero debe devolver una razón clara al expirar: &lt;code&gt;lease_expired&lt;/code&gt;, &lt;code&gt;message_not_found&lt;/code&gt; o &lt;code&gt;message_rejected&lt;/code&gt;. Un error así ayuda mucho mas que “Timeout waiting for email”.&lt;/p&gt;

&lt;p&gt;Para un flujo completo, también vale la pena &lt;a href="https://dev.to/hannahdev56/como-probar-upgrades-por-email-sin-ruido-en-tu-saas-53pl"&gt;probar upgrades por email sin ruido&lt;/a&gt; y revisar cómo mantener &lt;a href="https://dev.to/alexcarteruk/terraform-pruebas-de-email-sin-deriva-en-ci-n3k"&gt;pruebas de email sin deriva en CI&lt;/a&gt;. Son piezas distintas, pero comparten la misma regla: cada ejecución debe dejar evidencia propia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo llevarlo a CI
&lt;/h2&gt;

&lt;p&gt;Antes de lanzar el navegador o la llamada API, el job debe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;crear el lease y guardar su JSON como artefacto;&lt;/li&gt;
&lt;li&gt;pasar &lt;code&gt;lease_id&lt;/code&gt; y &lt;code&gt;mailbox&lt;/code&gt; como variables del test;&lt;/li&gt;
&lt;li&gt;registrar el momento exacto en que se dispara el email;&lt;/li&gt;
&lt;li&gt;adjuntar el mensaje seleccionado y la razón de selección;&lt;/li&gt;
&lt;li&gt;ejecutar la liberación en un paso de cleanup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cuando hay reintentos, conserva los artefactos por intento. Sobrescribirlos es tentador, pero hace que el segundo intento borre la evidencia del primero. Un nombre como &lt;code&gt;email-lease-{lease_id}.json&lt;/code&gt; ya ayuda a mantener el contexto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas rápidas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Un lease reemplaza una dirección aislada por test?
&lt;/h3&gt;

&lt;p&gt;No. La dirección aislada sigue siendo buena práctica. El lease añade tiempo, propiedad y estado, así que protege cuando la dirección se reutiliza o el proveedor no puede crear una nueva para cada caso.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Debo guardar el contenido completo del email?
&lt;/h3&gt;

&lt;p&gt;Solo si es necesario para depurar. En muchos casos basta con guardar asunto, remitente, fecha, identificador y un hash del cuerpo. Así reduces datos sensibles en los artefactos de CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué hago si el proveedor no tiene expiración?
&lt;/h3&gt;

&lt;p&gt;Mantén la expiración en tu propia base de datos y ejecuta una tarea de limpieza. La ausencia de una función remota no debe convertir el polling en un proceso sin fin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;Un contrato de lease no hace que el correo sea instantáneo. Hace algo más útil: define qué ejecución tiene derecho a leer qué mensaje y durante cuánto tiempo. Con FastAPI, unas rutas pequeñas, un &lt;code&gt;lease_id&lt;/code&gt; y un cleanup fiable bastan para empezar.&lt;/p&gt;

&lt;p&gt;La próxima vez que un test de verificación falle, busca primero la propiedad del inbox y la ventana temporal. Si esas dos cosas están documentadas, el equipo puede arreglar el sistema en vez de solo subir el timeout.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: callbacks de email sin duplicados</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:22:45 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-callbacks-de-email-sin-duplicados-48hd</link>
      <guid>https://dev.to/oliviachen7/fastapi-callbacks-de-email-sin-duplicados-48hd</guid>
      <description>&lt;p&gt;Cuando un servicio de email avisa que un mensaje fue entregado, abierto o rechazado, el callback parece una operación sencilla: recibir JSON, actualizar una fila y devolver &lt;code&gt;200 OK&lt;/code&gt;. En realidad, los proveedores reintentan, las redes cortan conexiones y dos eventos pueden llegar casi al mismo tiempo.&lt;/p&gt;

&lt;p&gt;En una API pequeña, esto puede terminar en estados imposibles: un mensaje marcado como entregado despues de haber sido rechazado, dos tareas de automatización para el mismo evento o un contador de notificaciones que crece cada vez que el proveedor repite la petición.&lt;/p&gt;

&lt;p&gt;La solución que suelo aplicar en backend es tratar cada callback como un evento con identidad propia. FastAPI ayuda con la validación y el tipado, pero la parte importante es el contrato de idempotencia: recibir el mismo evento dos veces debe tener el mismo efecto que recibirlo una sola vez.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema de los callbacks duplicados
&lt;/h2&gt;

&lt;p&gt;Supongamos que una aplicación envía un enlace de verificación. El proveedor acepta el mensaje y luego manda un callback &lt;code&gt;delivered&lt;/code&gt;. Si nuestra API tarda demasiado en responder, el proveedor puede reenviar el mismo evento. También puede ocurrir que el worker procese el primer evento mientras el segundo ya está esperando en la cola.&lt;/p&gt;

&lt;p&gt;Guardar solo &lt;code&gt;status = "delivered"&lt;/code&gt; no alcanza. Hace falta conservar un identificador del evento, el identificador del mensaje y un estado que permita decidir si una transición es válida. Tambien conviene guardar una marca de tiempo y el cuerpo mínimo necesario para investigar, sin copiar datos privados que no aportan.&lt;/p&gt;

&lt;p&gt;Un contrato práctico puede tener esta forma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_01J9ABC123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_7f31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email.delivered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-29T14:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;event_id&lt;/code&gt; identifica la notificación del proveedor. &lt;code&gt;message_id&lt;/code&gt; identifica el email dentro de nuestro sistema. Si el proveedor no entrega un &lt;code&gt;event_id&lt;/code&gt; estable, podemos calcular una huella de los campos confiables, aunque es mejor documentar esa decisión y no depender de todo el JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un contrato pequeño para el webhook
&lt;/h2&gt;

&lt;p&gt;Antes de escribir el endpoint, definiria tres reglas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Un &lt;code&gt;event_id&lt;/code&gt; nuevo se registra una sola vez.&lt;/li&gt;
&lt;li&gt;Un evento ya registrado devuelve éxito sin ejecutar otra vez el efecto.&lt;/li&gt;
&lt;li&gt;Una transición antigua no puede sobrescribir un estado más avanzado.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La segunda regla es importante. Devolver un error para cada repetición hace que el proveedor siga reintentando. Si el evento ya fue procesado, &lt;code&gt;200 OK&lt;/code&gt; significa “lo conozco y no necesito repetirlo”.&lt;/p&gt;

&lt;p&gt;Para las transiciones, una tabla sencilla puede guardar &lt;code&gt;received&lt;/code&gt;, &lt;code&gt;processed&lt;/code&gt;, &lt;code&gt;delivered&lt;/code&gt; y &lt;code&gt;failed&lt;/code&gt;. No todos los proveedores usan los mismos nombres, por eso es mejor mapear sus estados a un vocabulario interno. Un callback &lt;code&gt;opened&lt;/code&gt; no deberia cambiar un mensaje que ya está en &lt;code&gt;failed&lt;/code&gt; si ese cambio no está permitido por el producto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementación idempotente en FastAPI
&lt;/h2&gt;

&lt;p&gt;El endpoint puede validar la forma del evento y dejar el trabajo pesado a una tarea. La restricción única en la base de datos es la última defensa contra carreras entre peticiones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhooks/email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_email_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmailEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email-event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El ejemplo omite la implementación de &lt;code&gt;event_store&lt;/code&gt;, pero su operación &lt;code&gt;insert_once&lt;/code&gt; deberia apoyarse en un índice único para &lt;code&gt;event_id&lt;/code&gt;. No confiaría solamente en un &lt;code&gt;SELECT&lt;/code&gt; seguido de un &lt;code&gt;INSERT&lt;/code&gt;: dos requests pueden pasar el &lt;code&gt;SELECT&lt;/code&gt; antes de que cualquiera escriba.&lt;/p&gt;

&lt;p&gt;El worker puede aplicar la transición dentro de una transacción. Si falla después de registrar el evento, el sistema necesita una estrategia de reintento basada en el estado, no un evento nuevo con otro identificador. Un &lt;code&gt;correlation_id&lt;/code&gt; facilita seguir el recorrido desde el callback hasta la tarea que actualiza el mensaje.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pruebas y observabilidad del flujo
&lt;/h2&gt;

&lt;p&gt;Primero probaría la lógica con una secuencia pequeña:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enviar un callback nuevo y comprobar que se crea un registro.&lt;/li&gt;
&lt;li&gt;Enviar el mismo &lt;code&gt;event_id&lt;/code&gt; dos veces y comprobar que solo se encola una tarea.&lt;/li&gt;
&lt;li&gt;Enviar un evento antiguo despues de uno más avanzado y comprobar que el estado no retrocede.&lt;/li&gt;
&lt;li&gt;Simular un fallo del worker y verificar que el reintento es seguro.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;En una prueba de integración, una bandeja aislada permite revisar asunto, enlace y contenido sin usar una cuenta personal. Para ese tipo de verificación, un &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;correo temporal gratis&lt;/a&gt; puede servir como entorno descartable; no lo usaría para datos reales ni para ocultar la identidad de usuarios.&lt;/p&gt;

&lt;p&gt;Los logs deberían incluir &lt;code&gt;event_id&lt;/code&gt;, &lt;code&gt;message_id&lt;/code&gt;, tipo y resultado de la transición. No incluiría el cuerpo completo del email ni tokens. Un panel sencillo con eventos recibidos, duplicados, rechazados y tiempo de procesamiento responde preguntas mas utiles que un contador general de requests.&lt;/p&gt;

&lt;p&gt;Si la interfaz muestra el resultado del envío, el backend y el frontend deben compartir estados claros. Vale la pena &lt;a href="https://dev.to/silviutech/react-estados-async-accesibles-sin-saltos-3bc5"&gt;diseñar estados async accesibles sin saltos&lt;/a&gt; para que “procesando” no se confunda con “entregado”. Y cuando el usuario vuelve a abrir una pantalla, &lt;a href="https://dev.to/silviutech/react-verifica-emails-sin-perder-contexto-2ea9"&gt;verificar emails sin perder el contexto&lt;/a&gt; ayuda a no lanzar una segunda acción por accidente.&lt;/p&gt;

&lt;p&gt;En búsquedas y pruebas internas pueden aparecer expresiones como &lt;code&gt;tempmail so&lt;/code&gt; o el typo &lt;code&gt;tempail mail&lt;/code&gt;. Son términos de contenido, no contratos técnicos: no deberían terminar en nombres de eventos, tablas o respuestas de la API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para llevarlo a producción
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;¿Existe una restricción única para &lt;code&gt;event_id&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;¿Una repetición devuelve éxito sin repetir efectos?&lt;/li&gt;
&lt;li&gt;¿Las transiciones inválidas quedan registradas?&lt;/li&gt;
&lt;li&gt;¿El worker puede reintentarse sin duplicar notificaciones?&lt;/li&gt;
&lt;li&gt;¿Los logs evitan tokens y contenido privado?&lt;/li&gt;
&lt;li&gt;¿Hay una forma de relacionar callback, mensaje y tarea?&lt;/li&gt;
&lt;li&gt;¿El endpoint valida el tamaño y la forma del payload?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tambien revisaría qué ocurre cuando la base de datos está temporalmente fuera de servicio. En ese caso, devolver un error para que el proveedor reintente suele ser más seguro que confirmar un evento que nunca se guardó. El tiempo de respuesta debe ser corto; el procesamiento detallado puede ocurrir después.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas rápidas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Idempotencia significa ignorar todos los eventos repetidos?
&lt;/h3&gt;

&lt;p&gt;No. Significa que una repetición no vuelve a ejecutar el mismo efecto. Aun puede ser útil actualizar un contador de intentos o registrar una métrica de duplicados, siempre que eso sea una operación separada y controlada.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Debo procesar el callback antes de responder?
&lt;/h3&gt;

&lt;p&gt;Normalmente no. Valida, persiste o encola lo mínimo y responde. El worker puede hacer el cambio de estado con más tiempo y con reintentos. Una cola no arregla un contrato ambiguo, pero hace el flujo mas resistente cuando el proveedor entrega ráfagas.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué debo probar primero?
&lt;/h3&gt;

&lt;p&gt;El caso feliz y la repetición inmediata. Quizas el bug más caro no sea un callback que falla, sino uno que parece funcionar dos veces y manda dos mensajes o activa dos procesos.&lt;/p&gt;

&lt;p&gt;Un webhook de email confiable no necesita mucha magia. Necesita identidad, restricciones en la base de datos, transiciones explícitas y pruebas que imiten los reintentos reales. Con ese contrato, FastAPI deja de ser solo la puerta de entrada y se convierte en una base clara para automatizar sin sorpresas.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: un contrato para emails de prueba</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:22:44 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-un-contrato-para-emails-de-prueba-1bhl</link>
      <guid>https://dev.to/oliviachen7/fastapi-un-contrato-para-emails-de-prueba-1bhl</guid>
      <description>&lt;p&gt;Un endpoint de FastAPI puede devolver &lt;code&gt;202 Accepted&lt;/code&gt; en milisegundos y aun así dejar al test esperando un correo que llega después. En muchos proyectos, el problema no es el endpoint: es que la aplicación y el buzón de prueba no tienen un contrato claro.&lt;/p&gt;

&lt;p&gt;Cuando el test solo hace &lt;code&gt;sleep(5)&lt;/code&gt; y busca el último mensaje, los reintentos se vuelven confuso. Puede leer un email de una ejecución anterior, mezclar dos usuarios o fallar sin decir si el mensaje nunca salió. La solución que me resulta más práctica es tratar el buzón como una dependencia con reglas explícitas, igual que una API externa.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema: el endpoint funciona, pero el test no sabe qué esperar
&lt;/h2&gt;

&lt;p&gt;Imagina un registro que crea un usuario y dispara un email de confirmación. El test necesita saber al menos cuatro cosas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;qué identidad de prueba pertenece a la ejecución;&lt;/li&gt;
&lt;li&gt;desde qué instante debe aceptar mensajes;&lt;/li&gt;
&lt;li&gt;cómo reconocer el mensaje correcto;&lt;/li&gt;
&lt;li&gt;qué evidencia guardar si el mensaje no aparece.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sin esas reglas, "último email" parece una atajo, pero no es una aserción confiable. También dificulta auditar señales de onboarding en un SaaS cuando el flujo crece y hay varios eventos parecidos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué debe prometer el contrato del buzón
&lt;/h2&gt;

&lt;p&gt;Un contrato pequeño puede tener esta forma:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;El test obtiene un buzón único antes de llamar al endpoint.&lt;/li&gt;
&lt;li&gt;Guarda &lt;code&gt;inbox_id&lt;/code&gt; y &lt;code&gt;triggered_at&lt;/code&gt; junto con el caso de prueba.&lt;/li&gt;
&lt;li&gt;Solo acepta mensajes posteriores a &lt;code&gt;triggered_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verifica asunto, destinatario y un identificador de ejecución.&lt;/li&gt;
&lt;li&gt;Devuelve el mensaje elegido o un diagnóstico con lo que observó.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ese identificador puede ser un &lt;code&gt;run_id&lt;/code&gt; incluido en un header interno o en los datos del usuario. No hace falta inventar una plataforma compleja. Basta con que el criterio sea reproducible y que el test no dependa de coincidencias.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un cliente pequeño para esperar el mensaje correcto
&lt;/h2&gt;

&lt;p&gt;La parte útil puede vivir en una clase de Python. El cliente real puede hablar con el proveedor de correo, pero la interfaz del test debería quedarse simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wait_for_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;received_at&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Confirma tu cuenta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email no encontrado; inbox=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, vistos=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El detalle importante no es el &lt;code&gt;sleep&lt;/code&gt;, sino el filtro. El test espera una propiedad del mensaje y conserva los ids observados. Si la API de correo tiene paginación, conviene ordenar por fecha y deduplicar por id para que la misma notificación no se procese dos veces.&lt;/p&gt;

&lt;p&gt;Para aislar la integración, un &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; puede servir como bandeja temporal durante pruebas controladas. No lo usaría como una identidad permanente ni para datos reales: el objetivo es que cada ejecución tenga un buzón limitado y fácil de retirar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aislamiento, reintentos y limpieza
&lt;/h2&gt;

&lt;p&gt;Un reintento debe crear una nueva identidad de prueba o reutilizar la misma de forma deliberada. Reutilizar sin registrar la decisión hace que el segundo intento herede mensajes viejos. En CI, yo guardaría un objeto mínimo por intento:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ci-4821"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inbox_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox-91"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"triggered_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-28T14:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg-204"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"accepted"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;También define una política de limpieza. El buzón se debe cerrar después del test, incluso cuando falla una aserción. En Python, &lt;code&gt;try/finally&lt;/code&gt; suele ser suficiente; si la creación del buzón ocurre dentro de un fixture de pytest, el &lt;code&gt;yield&lt;/code&gt; deja ese ciclo bastante claro.&lt;/p&gt;

&lt;p&gt;Si varios tests comparten una cuenta por ahorrar tiempo, al menos usa un prefijo de ejecución en el destinatario o en el contenido. Un &lt;code&gt;dummy e mail&lt;/code&gt; escrito en una nota puede parecer igual que el correo real para un compañero que depura deprisa. Y "tem email" no explica nada si no viene acompañado de un id, una hora y una razón de descarte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para llevarlo a CI
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Crea el buzón antes de disparar el endpoint.&lt;/li&gt;
&lt;li&gt;Genera un &lt;code&gt;run_id&lt;/code&gt; único y pásalo al flujo.&lt;/li&gt;
&lt;li&gt;Filtra por fecha, destinatario, asunto y &lt;code&gt;run_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Registra los ids vistos y el motivo de cada descarte.&lt;/li&gt;
&lt;li&gt;Configura un timeout basado en polling, no en una espera fija.&lt;/li&gt;
&lt;li&gt;Limpia el buzón en éxito y en error.&lt;/li&gt;
&lt;li&gt;Evita imprimir tokens o el contenido completo del mensaje en logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para el caso de emails transaccionales, ayuda también &lt;a href="https://dev.to/silviutech/como-probar-emails-transaccionales-en-fastapi-sin-mezclar-bandejas-ni-eventos-5f0m"&gt;probar emails transaccionales sin mezclar bandejas&lt;/a&gt;. La idea encaja bien con FastAPI porque separa la lógica del endpoint de la lógica de observación. El test no necesita saber cómo se entrega el correo, solo qué contrato puede exigir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas rápidas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Cuánto debe esperar un test?
&lt;/h3&gt;

&lt;p&gt;Depende del entorno y del proveedor. Empieza con un límite razonable para CI y mide los tiempos reales. Si cada test espera 60 segundos, el problema es de diseño aunque casi siempre pase.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Debo buscar el email más reciente?
&lt;/h3&gt;

&lt;p&gt;Solo si además validas la identidad de la ejecución. Fecha reciente por sí sola no evita que dos mensajes lleguen juntos.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué registro es suficiente?
&lt;/h3&gt;

&lt;p&gt;El &lt;code&gt;run_id&lt;/code&gt;, el buzón, la hora de disparo, ids observados y la decisión final suelen dar una explicación rapida sin convertir el log en una copia del correo.&lt;/p&gt;

&lt;p&gt;Un contrato de recepción pequeño hace que la automatización sea menos misteriosa. Cuando un test falla, puedes ver si falló la API, la entrega o el filtro; y esa diferencia ahorra mas tiempo que añadir otro reintento ciego.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: contratos claros para emails de prueba</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Wed, 28 Oct 2026 11:22:45 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-contratos-claros-para-emails-de-prueba-4d5</link>
      <guid>https://dev.to/oliviachen7/fastapi-contratos-claros-para-emails-de-prueba-4d5</guid>
      <description>&lt;p&gt;Cuando un test solicita un email de verificación, muchas aplicaciones consideran que el trabajo terminó al recibir un &lt;code&gt;202 Accepted&lt;/code&gt;. Ese resultado solo dice que la API aceptó la petición. No confirma que el mensaje se haya generado, entregado al buzón de prueba o que el contenido sea el esperado.&lt;/p&gt;

&lt;p&gt;En proyectos Python he encontrado que el problema no suele ser enviar el email, sino definir qué significa que el flujo terminó bien. Un contrato pequeño en FastAPI puede separar esas etapas y hacer que un fallo sea mucho mas facil de investigar. Tambien evita que un test pase por accidente porque leyó un mensaje viejo.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema: aceptar no es verificar
&lt;/h2&gt;

&lt;p&gt;Imagina un registro de usuario con este flujo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;El test llama a &lt;code&gt;POST /users&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;La aplicación publica un evento de verificación.&lt;/li&gt;
&lt;li&gt;Un worker construye y envía el email.&lt;/li&gt;
&lt;li&gt;El buzón de prueba lo hace visible.&lt;/li&gt;
&lt;li&gt;El test lee el enlace y confirma la cuenta.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cada paso puede tener una latencia diferente. Si el endpoint responde de inmediato y el test busca el mensaje una sola vez, aparecerán fallos intermitentes. Si el buzón se reutiliza sin un identificador único, el test puede encontrar el email de otra ejecución y mostrar un falso positivo.&lt;/p&gt;

&lt;p&gt;Para el frontend, también es útil que cada estado sea explicable; este artículo sobre &lt;a href="https://dev.to/silviutech/react-estados-accesibles-para-email-de-prueba-34ef"&gt;estados accesibles para email de prueba&lt;/a&gt; muestra por qué “esperando” y “falló” no deberían verse como el mismo resultado.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define el contrato del flujo
&lt;/h2&gt;

&lt;p&gt;Antes de escribir el test, define una respuesta y una evidencia mínima. Por ejemplo, &lt;code&gt;POST /test-email&lt;/code&gt; puede devolver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ci-2026-1042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mailbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ci-2026-1042@example.test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"queued"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;run_id&lt;/code&gt; debe viajar en el evento y en el asunto o cuerpo del mensaje. El endpoint de lectura puede ser &lt;code&gt;GET /test-email/{run_id}&lt;/code&gt;, con estados como &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;delivered&lt;/code&gt;, &lt;code&gt;mismatch&lt;/code&gt; y &lt;code&gt;expired&lt;/code&gt;. Así el consumidor no necesita adivinar si “no encontrado” significa que el worker sigue trabajando o que el mensaje nunca llegará.&lt;/p&gt;

&lt;p&gt;Este contrato tambien debe especificar una ventana de espera, qué campos se comparan y qué datos se guardan. Para depurar normalmente alcanza con el identificador del mensaje, timestamps, asunto, destinatario de prueba y resultado de cada intento. Guardar todo el contenido durante meses es una decisión de privacidad que no deberia ser automática.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un endpoint FastAPI pequeño y observable
&lt;/h2&gt;

&lt;p&gt;La API no necesita hacer polling dentro de la petición que crea el usuario. Puede encolar el trabajo y exponer una consulta separada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/test-email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EmailRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;queue_test_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# La implementación real publica un evento idempotente.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EmailRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queued&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/test-email/{run_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EmailRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_test_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lookup_email_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El ejemplo es intencionalmente pequeño: el valor está en el límite entre estados, no en esconder toda la lógica en el endpoint. En producción, &lt;code&gt;lookup_email_run&lt;/code&gt; debería leer de una fuente consistente y no depender de una variable global mutable.&lt;/p&gt;

&lt;p&gt;La respuesta de estado también ayuda a medir activación y abandono. Si el producto tiene varios pasos de onboarding, conviene &lt;a href="https://dev.to/hannahdev56/como-revisar-cohortes-de-activacion-por-email-292i"&gt;revisar cohortes de activación por email&lt;/a&gt; con el mismo identificador de ejecución, en vez de mezclar eventos de usuarios reales y tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polling, idempotencia y limpieza
&lt;/h2&gt;

&lt;p&gt;El cliente puede consultar cada pocos segundos con un límite total. Un backoff de 2, 4, 8 y 16 segundos suele ser suficiente para un smoke test; no hace falta bombardear el buzón. Al terminar el plazo, devuelve un error con la última evidencia, no solo un &lt;code&gt;timeout&lt;/code&gt; genérico.&lt;/p&gt;

&lt;p&gt;La creación del evento debe ser idempotente. Si CI reintenta la petición por un corte de red, el mismo &lt;code&gt;run_id&lt;/code&gt; no debería crear dos usuarios ni dos procesos de verificación. El servidor puede conservar una clave de idempotencia corta y responder con el estado existente.&lt;/p&gt;

&lt;p&gt;También hay que separar “mensaje no visible todavía” de “mensaje incorrecto”. Un correo que aparece con otro &lt;code&gt;run_id&lt;/code&gt; suele indicar aislamiento roto; un asunto correcto pero sin enlace apunta a una plantilla; un buzón sin respuesta puede ser un problema de proveedor o autenticación. Esa distinción ahorra bastante tiempo en una guardia.&lt;/p&gt;

&lt;p&gt;Por último, limpia los mensajes y buzones al finalizar. Si la limpieza falla, registra el incidente sin ocultar el resultado principal del test. Para nombres de búsqueda que aparecen en documentación informal, alguien puede escribir “tempail” o “tem email”; el código y los contratos deben usar siempre la misma nomenclatura correcta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas frecuentes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Conviene esperar el email dentro de &lt;code&gt;POST /users&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;No para la mayoría de tests. Mantener la petición abierta mezcla creación de usuario con entrega de correo y hace mas dificil distinguir dónde falló el flujo. Un &lt;code&gt;202&lt;/code&gt; más una consulta de estado representa mejor el trabajo asíncrono.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Debo usar un buzón diferente por cada ejecución?
&lt;/h3&gt;

&lt;p&gt;Es lo mas sencillo. Si no es posible, añade un &lt;code&gt;run_id&lt;/code&gt; único, filtra por él y aplica una limpieza confiable. Reutilizar sin esos controles convierte un test rápido en una fuente de resultados dudosos.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué evidencia debería conservar CI?
&lt;/h3&gt;

&lt;p&gt;El identificador de ejecución, estado final, tiempos de cola y entrega, asunto, identificador del mensaje y causa de fallo. El cuerpo completo solo debe conservarse cuando sea necesario para depurar y con una retención corta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para el test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] La API devuelve un &lt;code&gt;run_id&lt;/code&gt; estable y un estado explícito.&lt;/li&gt;
&lt;li&gt;[ ] El evento y el mensaje incluyen el mismo identificador.&lt;/li&gt;
&lt;li&gt;[ ] La lectura distingue &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;delivered&lt;/code&gt;, &lt;code&gt;mismatch&lt;/code&gt; y &lt;code&gt;expired&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Los reintentos no duplican la operación de negocio.&lt;/li&gt;
&lt;li&gt;[ ] El polling tiene backoff y un límite total.&lt;/li&gt;
&lt;li&gt;[ ] CI guarda evidencia mínima y útil.&lt;/li&gt;
&lt;li&gt;[ ] Los mensajes de prueba se limpian después de cada ejecución.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Un contrato claro no hace que el email sea instantáneo, pero sí convierte una espera ambigua en una señal que el equipo puede entender. Con FastAPI, unos pocos estados bien definidos suelen dar más confianza que una cadena de reintentos sin contexto.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>FastAPI: contratos de tiempo para emails de prueba</title>
      <dc:creator>Olivia Cheng</dc:creator>
      <pubDate>Mon, 26 Oct 2026 20:22:50 +0000</pubDate>
      <link>https://dev.to/oliviachen7/fastapi-contratos-de-tiempo-para-emails-de-prueba-1j2</link>
      <guid>https://dev.to/oliviachen7/fastapi-contratos-de-tiempo-para-emails-de-prueba-1j2</guid>
      <description>&lt;p&gt;Cuando una prueba espera un email de verificación, el timeout suele ser el primer sospechoso. Pero subirlo de 30 a 90 segundos no arregla una prueba que no sabe distinguir entre un mensaje nuevo y uno viejo. En una API de FastAPI, el problema real suele ser la falta de un contrato de tiempo claro.&lt;/p&gt;

&lt;p&gt;Un contrato sencillo define cuándo empieza la espera, qué señales cuentan como éxito, cuántos reintentos se permiten y qué evidencia queda si la prueba falla. Esta idea hace que los emails de prueba sean más previsibles y que la automatización de CI tenga algo útil que explicar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema no es solo el timeout
&lt;/h2&gt;

&lt;p&gt;Imagina un endpoint que crea una cuenta y dispara un correo. El test hace una petición, consulta el buzón y busca el mensaje más reciente. Si el proveedor tarda, la consulta puede devolver cero mensajes. Si otro test comparte el buzón, puede devolver el mensaje equivocado. La prueba falla en la linea de lectura, aunque el bug esté en el aislamiento.&lt;/p&gt;

&lt;p&gt;También hay una carrera menos visible: la API responde antes de que el proveedor acepte el mensaje, pero el test empieza a buscar demasiado pronto. Un &lt;code&gt;sleep(5)&lt;/code&gt; parece ayudar, aunque solo mueve el problema. En el siguiente runner, el retraso puede ser diferente.&lt;/p&gt;

&lt;p&gt;La respuesta no es llamar a cualquier correo temporal y esperar más. Es registrar una referencia propia para cada ejecución: &lt;code&gt;run_id&lt;/code&gt;, instante de inicio y destinatario. Así la prueba sabe que debe aceptar un mensaje posterior a su inicio y dirigido a su identidad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define un contrato de tiempo
&lt;/h2&gt;

&lt;p&gt;Antes de hacer polling, crea un objeto pequeño con las reglas de la espera:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailWait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
    &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El contrato hace explícito algo que normalmente queda repartido entre fixtures y variables de entorno. &lt;code&gt;started_at&lt;/code&gt; filtra mensajes antiguos, &lt;code&gt;deadline&lt;/code&gt; limita el coste y &lt;code&gt;run_id&lt;/code&gt; permite unir el resultado con el log de CI. Si la prueba utiliza una dirección compartida, el destinatario solo no será suficiente: añade también un asunto o una marca única.&lt;/p&gt;

&lt;p&gt;Conviene medir el tiempo desde una señal estable. Si el endpoint devuelve &lt;code&gt;202 Accepted&lt;/code&gt;, empieza la espera cuando la API confirma que el trabajo fue encolado, no varios pasos antes. Para procesos largos, &lt;a href="https://dev.to/alexcarteruk/un-slo-para-emails-de-ci-en-kubernetes-22e4"&gt;un SLO para emails de CI&lt;/a&gt; ayuda a convertir esa expectativa en una métrica operable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementa el polling en FastAPI
&lt;/h2&gt;

&lt;p&gt;La ruta no debería bloquearse durante toda la espera. Una opción práctica es que el endpoint de prueba cree un trabajo y que un worker consulte el proveedor. El código del test puede usar un cliente asíncrono con pausas cortas y un límite absoluto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wait_for_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmailWait&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;received_at&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No llegó un email para &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; antes del límite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En producción, el adaptador de &lt;code&gt;mailbox&lt;/code&gt; puede llamar a una API externa; en tests unitarios, puede ser un fake. Esa separación vuelve la prueba más rápida y permite comprobar los casos difíciles: mensaje tardío, respuesta vacía, duplicados y error temporal del proveedor. Un detalle pequeño pero importante es que el &lt;code&gt;sleep&lt;/code&gt; debe tener una pausa razonable, no un bucle ocupado que consuma CPU.&lt;/p&gt;

&lt;p&gt;No escondas todos los errores detrás de &lt;code&gt;TimeoutError&lt;/code&gt;. Si el proveedor devuelve &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;429&lt;/code&gt; o &lt;code&gt;500&lt;/code&gt;, guarda esa diferencia en el resultado. Un retry para un &lt;code&gt;429&lt;/code&gt; tiene sentido; repetir una credencial inválida solo hace el diagnóstico más confuso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guarda evidencia sin guardar todo el correo
&lt;/h2&gt;

&lt;p&gt;Cuando falla un test, conserva un resumen seguro: &lt;code&gt;run_id&lt;/code&gt;, destinatario anonimizado, número de mensajes vistos, IDs de mensajes, timestamps y causa final. No hace falta guardar el cuerpo completo, porque puede contener tokens o datos personales.&lt;/p&gt;

&lt;p&gt;En los logs, normaliza los errores de entrada. Una búsqueda mal escrita como &lt;code&gt;temp gamil com&lt;/code&gt; no debe transformarse en una regla de producción ni en un filtro de aceptación. Parece obvio, pero este tipo de texto acaba a veces en variables de configuración copiadas de una incidencia.&lt;/p&gt;

&lt;p&gt;Para interfaces que muestran el estado del trabajo, los mensajes de error también deben ser estables. Las ideas de &lt;a href="https://dev.to/silviutech/react-errores-inline-sin-mover-el-formulario-b84"&gt;formularios con errores accesibles&lt;/a&gt; aplican aquí: una persona debe entender si el proceso está esperando, terminó o necesita reintento, sin depender solo del color.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist para CI
&lt;/h2&gt;

&lt;p&gt;Antes de dar por estable el flujo, comprueba lo siguiente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cada ejecución tiene un &lt;code&gt;run_id&lt;/code&gt; único y un instante de inicio.&lt;/li&gt;
&lt;li&gt;El filtro usa destinatario, timestamp y una señal del flujo.&lt;/li&gt;
&lt;li&gt;Hay un límite absoluto; el polling no es infinito.&lt;/li&gt;
&lt;li&gt;Los códigos &lt;code&gt;429&lt;/code&gt; y &lt;code&gt;5xx&lt;/code&gt; tienen reintentos acotados.&lt;/li&gt;
&lt;li&gt;El reporte conserva IDs y tiempos, pero no el contenido sensible.&lt;/li&gt;
&lt;li&gt;La limpieza ocurre en un bloque &lt;code&gt;finally&lt;/code&gt;, incluso tras una aserción fallida.&lt;/li&gt;
&lt;li&gt;El adaptador externo tiene un fake para tests unitarios.&lt;/li&gt;
&lt;li&gt;Un fallo de entrega se distingue de un fallo de parsing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Esto tambien facilita comparar ejecuciones. Si el tiempo de entrega crece durante varios días, tienes una señal para investigar antes de que todos los tests se vuelvan lentos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preguntas frecuentes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ¿Debo esperar en el endpoint de FastAPI?
&lt;/h3&gt;

&lt;p&gt;Normalmente no. Devuelve una respuesta que represente el trabajo encolado y deja el polling a un worker o al cliente de pruebas. Bloquear el request puede agotar los workers de la aplicación cuando el proveedor de correo está lento.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Cuántos reintentos son suficientes?
&lt;/h3&gt;

&lt;p&gt;Depende del proveedor, pero el límite debe estar relacionado con el presupuesto de la prueba. Tres reintentos rápidos no son una garantía; son una política que debe quedar visible y medirse.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Qué hago con un mensaje duplicado?
&lt;/h3&gt;

&lt;p&gt;Trátalo como un resultado ambiguo y registra sus IDs. Elegir silenciosamente el primero puede ocultar una entrega repetida o una fuga entre pruebas.&lt;/p&gt;

&lt;p&gt;El objetivo no es hacer que el email llegue siempre rápido. Es convertir una dependencia externa en una operación con identidad, tiempo y evidencia. Con ese contrato, FastAPI queda más fácil de probar, y la automatización deja de depender de pausas mágicas.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>testing</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
