Cuando un upgrade de FastAPI se convierte en una migracion de plataforma
Un caso practico de EDVM sobre bounds de version en FastAPI, wrappers de compatibilidad, telemetria de requests y rollout gradual en una plataforma de salud.
No toda migracion riesgosa parece riesgosa al principio.
EDVM fue convocado para ayudar a una empresa grande de software del sector salud a manejar lo que, al comienzo, parecia un upgrade moderado de FastAPI. La compania opera a escala real dentro del ecosistema de cuidados de salud en el hogar en Estados Unidos, con alrededor de 350 agencias y mas de 100 millones de visitas documentadas por ano. En ese contexto, un cambio acotado en el comportamiento de un framework puede convertirse rapidamente en un problema operativo.
El disparador fue FastAPI 0.132.0, que introdujo una validacion mas estricta del Content-Type para requests JSON. Los requests con body JSON ahora necesitan un header Content-Type valido para JSON, como application/json, o FastAPI puede rechazarlos antes de que el codigo de aplicacion procese el request.
Ese default es bueno. Vuelve mas explicita la frontera HTTP y reduce ambiguedades alrededor del parseo de requests.
Tambien es el tipo de cambio que puede romper integraciones reales. En una plataforma multi-repo con servicios internos, clientes antiguos, integraciones externas y APIs desplegadas de forma independiente, la parte dificil no es solo actualizar el framework. La parte dificil es controlar cuando cada servicio cruza la frontera de comportamiento y como detectar callers legacy antes de retirar la compatibilidad.
El problema real de migracion
La pregunta no era simplemente:
Hay que actualizar FastAPI?
La pregunta real era:
Como pinear, auditar, actualizar, observar y finalmente hacer enforce de un cambio de comportamiento del framework en varios repositorios sin romper clientes que todavia dependen del comportamiento legado?
Eso requeria cinco controles separados:
- identificar todos los repositorios expuestos al cambio de comportamiento de FastAPI,
- evitar upgrades accidentales mientras la auditoria estaba en curso,
- separar superficies de request donde era seguro hacer enforcement de aquellas que todavia necesitaban compatibilidad,
- preservar retrocompatibilidad para clientes que omitian el header esperado,
- recolectar suficiente telemetria para retirar el modo de compatibilidad con evidencia, no con suposiciones.
El quinto punto importaba tanto como los primeros cuatro. La compatibilidad sin telemetria solo oculta el problema. Mantiene el trafico funcionando, pero no le dice al equipo de plataforma que clientes todavia necesitan cambiar.
Los bounds de version son parte de la arquitectura
Una de las conclusiones mas claras de este trabajo es que la politica de dependencias es parte de la arquitectura, no una tarea de mantenimiento menor.
En servicios Python es comun ver dependencias de framework declaradas con lower bounds amplios:
[project]
dependencies = [
"fastapi>=0.110",
]
Eso parece razonable cuando la dependencia es estable y los upgrades son rutinarios. Es riesgoso cuando una version posterior cambia el comportamiento en una frontera de API. El resolver puede seleccionar legalmente una version contra la que el servicio nunca fue revisado.
Durante la contencion, preferimos un pin explicito:
[project]
dependencies = [
"fastapi==0.131.0",
]
O, cuando el equipo quiere flexibilidad controlada dentro de una linea aprobada:
[project]
dependencies = [
"fastapi>=0.131,<0.132",
]
Esas declaraciones expresan intenciones operativas distintas:
==significa “no se mueve hasta que decidamos cambiarlo intencionalmente”.<0.132significa “aceptamos movimiento de parches dentro de la linea aprobada, pero no cruzamos esta frontera de comportamiento”.
Esa diferencia importa cuando varios repositorios se despliegan de forma independiente. Un equipo de plataforma no puede controlar el riesgo de rollout si cada servicio puede derivar hacia un comportamiento nuevo en su propio calendario.
Construir el inventario de dependencias
Antes de cambiar codigo de aplicacion, construimos un inventario.
En un entorno multi-repo, el primer error es asumir que ya sabes donde vive una dependencia. FastAPI puede aparecer en servicios de aplicacion, APIs internas, adaptadores de integracion, workers con endpoints administrativos, repositorios plantilla y paquetes compartidos usados por servicios mas nuevos.
La auditoria empezo buscando declaraciones de dependencias y lock files en todo el portfolio. El objetivo no era solo encontrar repositorios que ya usaban FastAPI, sino tambien repositorios cuyos bounds permitian resolver hacia la version con cambio de comportamiento durante el proximo install o refresh de lock.
Una busqueda simplificada se ve asi:
rg -n 'fastapi' . \
-g 'pyproject.toml' \
-g 'requirements*.txt' \
-g 'poetry.lock' \
-g 'uv.lock'
El listado bruto es solo el primer paso. El trabajo util es la clasificacion:
- repositorios ya pineados por debajo de la frontera de comportamiento,
- repositorios con bounds laxos y por lo tanto expuestos en el proximo refresh de dependencias,
- repositorios ya actualizados,
- repositorios que heredaban FastAPI a traves de paquetes internos compartidos,
- repositorios con endpoints consumidos externamente y mayor riesgo de compatibilidad.
Eso convirtio una preocupacion difusa de plataforma en un tablero de migracion concreto. Cada repositorio tenia owner, estado actual de dependencia, estado de compatibilidad y camino hacia enforcement.
Contencion antes de migracion
La primera ola fue de contencion.
No tratamos cada repositorio como un debate aislado. Eso genera deriva de versiones, razonamiento duplicado y confusion de rollout. En cambio, aplicamos la misma politica temporal de versionado en los repositorios afectados para que la plataforma dejara de moverse mientras corria la auditoria.
La regla temporal fue simple:
- pinear servicios no auditados a una version conocida y segura de FastAPI,
- bloquear resolucion de dependencias mas alla de la frontera de comportamiento,
- refrescar lock files para que CI reflejara el estado deseado,
- flexibilizar o quitar el pin solo despues de que el servicio completara la revision de compatibilidad.
Esta fase no termina la migracion, pero evita que la migracion ocurra por accidente. Esa es la parte que muchos equipos saltean. Empiezan a probar un servicio mientras otro resuelve silenciosamente una version mas nueva del framework y despliega un comportamiento distinto en produccion.
La compatibilidad solo sirve si es observable
El cambio giraba alrededor de requests JSON sin un header Content-Type valido.
FastAPI ofrece una configuracion de compatibilidad a nivel de aplicacion:
from fastapi import FastAPI
app = FastAPI(strict_content_type=False)
Eso recupera el comportamiento anterior para clientes que envian un body JSON sin header Content-Type. Es util cuando algunos callers no pueden corregirse de inmediato.
Pero esa configuracion no deberia convertirse en un punto ciego permanente. Desactivar el chequeo estricto puede ser la decision correcta durante una migracion, pero solo si la plataforma tambien registra que clientes siguen dependiendo de ese camino de compatibilidad.
El patron que usamos fue un wrapper pequeno alrededor de la creacion de apps FastAPI. Dio a los servicios un lugar consistente para configurar compatibilidad temporal y un lugar consistente para agregar diagnosticos de requests.
import logging
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("api.compatibility")
class JsonContentTypeAuditMiddleware(BaseHTTPMiddleware):
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
content_type = request.headers.get("content-type")
content_length = request.headers.get("content-length", "0")
has_json_candidate_body = (
request.method in {"POST", "PUT", "PATCH"}
and content_length != "0"
and not content_type
)
if has_json_candidate_body:
client_id = request.headers.get("x-client-id", "unknown")
logger.warning(
"legacy_json_content_type_detected",
extra={
"client_id": client_id,
"method": request.method,
"path": request.url.path,
"content_type": None,
},
)
return await call_next(request)
def create_app() -> FastAPI:
app = FastAPI(strict_content_type=False)
app.add_middleware(JsonContentTypeAuditMiddleware)
return app
El detalle importante es lo que este codigo no registra. No guarda bodies de requests, datos de pacientes, fragmentos de payload, valores de autorizacion ni nada que pueda contener informacion protegida de salud. Para una plataforma de salud, la senal util de migracion es metadata: identidad del cliente, metodo, ruta, servicio y si faltaba el header esperado.
Esa senal se convirtio en una lista practica de migracion. En lugar de enviar un mensaje generico a todos los owners de integraciones, el equipo pudo enfocarse en los clientes efectivamente observados enviando requests legacy:
- identificador del cliente,
- ruta afectada,
- servicio que recibio el request,
- metodo del request,
- fecha en que el comportamiento fue observado por ultima vez.
Eso cambio el rollout: de advertencia amplia a coordinacion basada en evidencia.
Separar fronteras seguras de fronteras riesgosas
Un upgrade de dependencias se vuelve riesgoso cuando distintas fronteras de API tienen distinto comportamiento del lado del caller.
Clasificamos endpoints segun ownership del caller y control de migracion:
- callers internos,
- integraciones coordinadas externamente,
- clientes legacy con disciplina incompleta sobre headers,
- workflows donde el riesgo de rechazo no era aceptable durante la transicion,
- servicios donde el comportamiento estricto podia habilitarse de inmediato.
Eso cambio la revision. Ya no preguntabamos si toda la plataforma estaba “lista” en abstracto. Preguntabamos que fronteras estaban listas, cuales no, y que politica requeria cada frontera.
La revision tecnica se concentro en:
- ejemplos de requests capturados desde tests y logs,
- SDKs o clientes que construian requests JSON,
- capas proxy que podian normalizar, agregar o perder headers,
- grupos de endpoints con mayor probabilidad de recibir trafico de clientes antiguos,
- tests a nivel de servicio que probaran comportamiento estricto donde el enforcement ya era seguro.
Muchos bugs de compatibilidad no viven en el codigo del servidor. Viven en callers, API gateways, scripts antiguos de integracion, fixtures de tests e implementaciones de clientes no oficiales. Una migracion de framework que ignora esas fronteras solo esta auditada parcialmente.
El plan de rollout
La migracion se volvio manejable cuando tratamos contencion, compatibilidad, telemetria y enforcement como fases separadas.
Fase 1: Contener
- Pinear FastAPI por debajo de la version con cambio de comportamiento en los repositorios afectados.
- Evitar deriva del resolver.
- Construir el inventario de dependencias y servicios.
Fase 2: Auditar
- Identificar superficies que aceptan request bodies JSON.
- Revisar que callers envian headers
Content-Typevalidos. - Marcar integraciones de alto riesgo y clientes legacy.
- Decidir donde se justifica el modo de compatibilidad.
Fase 3: Actualizar y observar
- Mover servicios auditados a la version nueva de FastAPI.
- Usar
strict_content_type=Falsesolo donde todavia hace falta retrocompatibilidad. - Loguear o emitir metricas para requests legacy sin el header esperado.
- Mantener comportamiento estricto en servicios y endpoints que ya estan limpios.
Fase 4: Notificar y trackear
- Notificar solo a clientes observados usando comportamiento legacy.
- Trackear trafico no conforme por cliente, ruta y servicio.
- Confirmar fixes con telemetria de produccion, no con suposiciones.
- Mantener un burndown visible de la ventana de compatibilidad.
Fase 5: Hacer enforcement
- Corregir o bloquear los clientes no conformes restantes.
- Agregar tests que aseguren que los clientes soportados envian
Content-Type: application/jsonpara bodies JSON. - Retirar el modo de compatibilidad cuando la frontera este limpia.
- Mantener bounds de version explicitos para que futuros upgrades crucen fronteras de comportamiento de forma intencional.
Este enfoque es mas lento que actualizar un repositorio a ciegas. Es mucho mas rapido que resolver un incidente productivo repartido entre varios servicios.
Como endurecer el patron
El patron de wrapper funciono porque preservo compatibilidad sin sacrificar visibilidad. Hay varias formas de fortalecerlo.
Primero, emitir metricas estructuradas ademas de logs. Los logs sirven para investigar, pero las metricas hacen visible el avance de la migracion en el tiempo. Un dashboard con conteos de requests no conformes por cliente, ruta y servicio le da a plataforma un burndown de compatibilidad.
Segundo, poner el comportamiento de compatibilidad detras de un feature flag. Eso permite probar comportamiento estricto en staging, activarlo para un servicio canary o reactivar temporalmente la compatibilidad durante un rollback controlado sin cambiar codigo de aplicacion.
Tercero, agregar una senal de deprecacion a nivel de response para requests afectados cuando corresponda. La notificacion directa a clientes sigue siendo importante, especialmente para integraciones externas, pero un header como Deprecation: true puede ayudar a owners de SDKs y equipos de integracion a detectar el problema en sus propios logs.
Cuarto, incluir SDKs oficiales y ejemplos de integracion en la suite de contract tests. Los tests server-side deberian verificar comportamiento estricto, pero los tests client-side tambien deberian probar que los clientes soportados siempre envian los headers correctos cuando mandan bodies JSON.
Finalmente, asignar owner y fecha de retiro al modo de compatibilidad. La compatibilidad temporal se convierte en deuda tecnica cuando no tiene vencimiento. El estado temporal deberia ser visible en codigo, configuracion, dashboards y planificacion de releases.
Lo que esto mostro sobre gobierno de dependencias
La leccion central es que los bounds de version son parte del diseno del sistema.
Una buena disciplina en pyproject.toml no sirve solo para instalaciones reproducibles. Le da al equipo un lenguaje para expresar intencion operativa:
- pins exactos para congelar y contener,
- rangos acotados para flexibilidad controlada,
- revision explicita antes de cruzar fronteras de comportamiento conocidas,
- refreshes de lock files que hacen que CI pruebe el estado de dependencias esperado.
En una plataforma multi-repo, esa disciplina reduce tres fallas comunes:
- deriva silenciosa entre servicios,
- upgrades parciales que crean comportamientos inconsistentes,
- debugging de emergencia despues de que un resolver toma una decision que nadie reviso.
Lo que EDVM aporto aqui no fue una configuracion puntual de framework. Fue una estrategia de migracion construida alrededor de control operativo: descubrimiento transversal de dependencias, pinning coordinado, wrappers de compatibilidad, telemetria para clientes legacy, notificacion dirigida y un camino gradual de vuelta hacia defaults mas estrictos.
Para equipos que operan sistemas de salud complejos, esa es la diferencia entre “actualizamos una dependencia” y “migramos una frontera de riesgo bajo control”.
Fuentes publicas
Release notes de FastAPI 0.132.0: FastAPI release notes
Comportamiento estricto de Content-Type y configuracion de compatibilidad: Strict Content-Type Checking
Queres construir algo parecido?
Si estas validando un producto, ordenando operaciones o armando un MVP tecnico, podemos ayudarte.