No necesitas un SaaS de video para poner a dos personas en una llamada. Con un plano de medios administrado (aquí AWS Chime SDK, pero la forma se generaliza), el lado del servidor de una videollamada 1:1 es una reunión, dos tokens de ingreso, y un interruptor de apagado. Este post construye exactamente eso, una llamada host↔guest con grabación, sobre FastAPI + SQLAlchemy + un cliente de React, y mantiene la cuenta predecible.
elchesco
/
blog-video-conferencing-code
Code companion — a 1:1 video call service
Code companion — a 1:1 video call service
Suggested reading order
-
00-starting-point/NOTES.md— why buy the media plane, build the rest. -
01-meeting-attendee/video_service.py— the whole SDK surface. -
02-idempotent-join/video_api.py— the only entry point that matters. -
03-cost-cap/video_reaper.py— the feature that bounds the bill. -
04-recording/— async pipeline + theprocessingrace. -
05-consent-audit/— the security pass.
The cost arithmetic (don't skip it)
~$0.0017 per attendee-minute × 2 attendees × 60 min = ~$0.20 / session
The happy path is cheap. The risk is the call nobody ended — design the
reaper (03-cost-cap/) before the End button.
Snippets vs full files
A file headed # (snippet — paste into ...) is partial. Everything else
is a full module you can drop in and adjust imports.
TL;DR
| Asunto | Decisión | Por qué |
|---|---|---|
| Construir vs comprar el plano de medios | Comprar (SDK administrado) | TURN/STUN, SFU, códecs, jitter buffers: no son tu negocio |
| Construir vs comprar la orquestación | Construir | Es una reunión + 2 tokens + control de acceso; ~180 líneas |
| Costo por sesión de 60 min | ~$0.20 | ~$0.0017 / minuto-asistente × 2 asistentes × 60 |
| Techo de costo | Worker interruptor de 60 min | Una pestaña olvidada abierta no puede inflar la cuenta |
| Asistentes | Tope duro de 2 | Forzado del lado del servidor, no solo en la UI |
| Grabación | Opcional, iniciada por el host, S3 privado | Consentimiento + auditoría, ciclo de vida de 90 días |
El punto de partida: qué necesita de verdad una llamada 1:1
El instinto es o (a) pagar un SaaS de video por asiento, o (b) cablear WebRTC desde cero. Los dos están complicados para una función 1:1 simple.
(a) es exagerado y costo recurrente. (b) significa ser dueño de la
señalización, de los servidores STUN/TURN, de un SFU para cualquier cosa más allá de peer-to-peer, de la negociación de códecs, y de la resiliencia de red: meses de trabajo para igualar lo que un plano administrado te da en una tarde.
El camino intermedio del SDK administrado divide las responsabilidades limpio:
tu servidor → crear la reunión, acuñar tokens de ingreso por usuario, gatear el acceso
plano administrado → enrutamiento de medios, TURN, SFU, tubería de grabación
SDK del navegador → capturar dispositivos, renderizar las tiles, mandar/recibir medios
Tu servidor nunca toca un solo paquete de medios. Le entrega al navegador un objeto meeting y un token de attendee, y el SDK del navegador hace el resto. Así que todo el backend es: crear reunión, crear asistente, borrar reunión, más el control de acceso alrededor de ellos.
Fase 1: el modelo de reunión + asistente
Un envoltorio delgado alrededor de la API de reuniones. Dos hechos manejan cada decisión aquí: una reunión es barata de crear y un token es por usuario.
# myapp/services/video_service.py (extracto)
import uuid
import boto3
MAX_ATTENDEES = 2 # host + guest, nadie más
MAX_DURATION_MIN = 60 # el worker fuerza esto
def _client():
# Fija la región de medios. Una región = lo más simple + lo más barato.
return boto3.client("chime-sdk-meetings", region_name="us-east-1")
def create_meeting(*, external_meeting_id: str) -> dict:
resp = _client().create_meeting(
ClientRequestToken=str(uuid.uuid4()),
MediaRegion="us-east-1",
ExternalMeetingId=external_meeting_id, # = tu sessions.id
)
return resp["Meeting"]
def create_attendee(*, meeting_id: str, external_user_id: str) -> dict:
resp = _client().create_attendee(
MeetingId=meeting_id,
ExternalUserId=external_user_id, # = tu users.id
)
return resp["Attendee"]
Dos detalles que no son obvios:
-
ExternalMeetingIdyExternalUserIdson tus llaves de correlación. Ponlos a tu propiosessions.idyusers.id. Cuando te quedes viendo un log del plano de medios seis semanas después, esos IDs son el único hilo de regreso a una fila real. No los dejes en blanco. - Fija una sola región de medios. Las reuniones entre regiones existen y casi nunca las quieres para una función simple: una sola región es más barata y quita toda una clase de preguntas de "¿por qué la latencia está rara?".
El modelo de datos son cuatro columnas anulables sobre la tabla sessions que ya existe, sin tabla nueva:
# alembic: agregar a sessions
sa.Column("meeting_id", sa.String(128), nullable=True)
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True)
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True)
meeting_id IS NOT NULL AND ended_at IS NULL significa "la llamada está en vivo". Ese único predicado es lo que leen tanto el endpoint de estatus como el worker.
Fase 2: el ingreso tiene que ser idempotente
El endpoint join ingenuo ("crea reunión, crea asistente, regresa") se rompe en el momento en que dos humanos reales lo usan. Los modos de falla:
Las dos partes hacen clic en Join dentro del mismo segundo → dos
llamadas a create_meeting → dos reuniones, cada una con un asistente solito.
Una parte hace doble clic → dos tokens de asistente para el mismo usuario.
Un reintento con carrera acuña un tercer token, rebasando el tope de 2 asistentes.
La solución es hacer de join el único punto de entrada idempotente. El primer que llama crea la reunión; todos los demás después la reutilizan. Y create_attendee se vuelve idempotente por usuario:
def create_attendee(*, meeting_id: str, external_user_id: str) -> dict:
client = _client()
existing = client.list_attendees(MeetingId=meeting_id).get("Attendees", [])
# El mismo usuario ingresando de nuevo → regresa su token, no acuñes uno nuevo.
for att in existing:
if att.get("ExternalUserId") == external_user_id:
return att
# Tope duro en la capa de medios, no solo en la UI. Un doble clic o un
# /join paralelo con carrera no debe poder acuñar un tercer token.
if len(existing) >= MAX_ATTENDEES:
raise PermissionError(f"Meeting already has {MAX_ATTENDEES} attendees.")
return client.create_attendee(
MeetingId=meeting_id, ExternalUserId=external_user_id,
)["Attendee"]
Y el endpoint crea-o-reutiliza la reunión debajo de la fila:
@router.post("/sessions/{session_id}/video/join")
async def video_join(session_id, current_user=Depends(get_active_user), db=...):
row = await _load_session(session_id, current_user, db) # 403 a los no-participantes
if row.ended_at is not None:
raise HTTPException(410, "This session already ended.")
if row.meeting_id is None:
meeting = video_service.create_meeting(external_meeting_id=str(row.id))
row.meeting_id = meeting["MeetingId"]
row.started_at = datetime.now(timezone.utc)
else:
meeting = video_service.get_meeting(row.meeting_id) # ve Trampa 1
attendee = video_service.create_attendee(
meeting_id=row.meeting_id, external_user_id=str(current_user.id),
)
await db.commit()
return {"meeting": meeting, "attendee": attendee}
El chequeo de acceso es todo el modelo de seguridad: solo los dos
participantes de la fila pueden ingresar. Sin puerta trasera de admin, "solo host y guest" era la regla explícita, y un bypass de admin es justo el tipo de cosa que calladito se vuelve un incidente de privacidad.
async def _load_session(session_id, user, db):
row = await db.get(Session, session_id)
if row is None:
raise HTTPException(404, "Session not found.")
if user.id not in (row.host_id, row.guest_id):
raise HTTPException(403, "Only the host and guest can join this call.")
return row
Trampa 1: la reunión se evapora y tu fila no se entera
El plano de medios recolecta como basura las reuniones inactivas. Así que meeting_id IS NOT NULL no garantiza que la reunión todavía exista. El segundo que llama a join pide una reunión muerta y se lleva una excepción. Manéjalo cerrando la sesión para que el siguiente clic empiece limpio, en lugar de regresar un 500:
try:
meeting = video_service.get_meeting(row.meeting_id)["Meeting"]
except Exception:
row.ended_at = datetime.now(timezone.utc) # la sala expiró
await db.commit()
raise HTTPException(410, "The room expired. Start a new session.")
Al pie de la letra, el error que lanza el plano cuando te saltas esto:
botocore.errorfactory.NotFoundException: An error occurred (NotFoundException)
when calling the GetMeeting operation: Meeting not found
Un 410 que el cliente entiende ("la sala expiró, empieza de nuevo") le gana a un 500 por el que el cliente entra en pánico.
Fase 3: el tope de costo es una función, no algo de último momento
Aquí es donde la medición maneja el diseño. El precio del plano de medios es más o menos $0.0017 por minuto-asistente. Una llamada 1:1 son 2 asistentes:
2 asistentes × 60 min × $0.0017 = $0.204 por sesión completa
$0.20 está bien. El peligro no es la ruta feliz: es la llamada que nadie terminó. Los navegadores mantienen viva la conexión del SDK en una pestaña en segundo plano; un participante que cierra su laptop sin hacer clic en End deja la reunión corriendo. Si la dejas sola, una pestaña olvidada factura hasta el propio timeout de inactividad del plano, y te enteras en la factura.
delete_meeting con un clic explícito en End cubre el caso educado. No cubre el caso abandonado. Así que el techo de costo es un worker: un tick de 60 segundos que desmantela cualquier cosa más vieja que el tope:
# myapp/workers/video_reaper.py
_INTERVAL_SECONDS = 60
async def _tick():
cutoff = datetime.now(timezone.utc) - timedelta(minutes=MAX_DURATION_MIN)
rows = await db.execute(
select(Session).where(
Session.meeting_id.isnot(None),
Session.ended_at.is_(None),
Session.started_at < cutoff,
)
)
for row in rows.scalars():
video_service.delete_meeting(meeting_id=row.meeting_id)
row.ended_at = datetime.now(timezone.utc)
await db.commit()
El número de 60 minutos es un tope duro del que depende el resto del
sistema: el worker lo fuerza, y el cliente muestra una cuenta regresiva derivada de la misma constante. Cámbialo en un solo lugar y los dos siguen. (Mantén MAX_DURATION_MIN en el módulo del servicio e impórtalo en todos lados: un 60 mágico regado entre el worker + el cliente es como la cuenta regresiva y el interruptor de apagado se desacuerdan en silencio.)
delete_meeting es idempotente a propósito: el worker y un End explícito pueden hacer carrera, y un "la reunión ya no está" tiene que ser un no-op, no un crash:
def delete_meeting(*, meeting_id: str) -> None:
try:
_client().delete_meeting(MeetingId=meeting_id)
except Exception as exc:
# Ya desmantelada → bien. El ended_at de la base de datos es la fuente de verdad.
logger.info("delete_meeting swallowed: %s", exc)
La base de datos es la fuente de verdad para "¿ya terminó esto?", no el plano de medios. El plano es limpieza de mejor esfuerzo; el ended_at es el hecho.
Fase 4: la grabación, las partes que muerden
La grabación es una tubería administrada aparte (aquí, media capture
pipelines) que escribe un MP4 a tu bucket de S3. La superficie del servicio es chica:
def start_recording(*, meeting_id: str, session_id: str) -> str:
resp = _pipelines_client().create_media_capture_pipeline(
SourceType="ChimeSdkMeeting",
SourceArn=_meeting_arn(meeting_id),
SinkType="S3Bucket",
SinkArn=f"arn:aws:s3:::{BUCKET}/sessions/{session_id}/", # ¡prefijo de llave!
ClientRequestToken=str(uuid.uuid4()),
)
return resp["MediaCapturePipeline"]["MediaPipelineId"]
El detalle que muerde: la tubería escribe el archivo final de manera asíncrona, después de que la reunión termina. Hay una ventana donde la grabación "existe" pero el MP4 todavía no está en S3. Así que el endpoint de lectura es una máquina de tres estados, no un booleano:
@router.get("/sessions/{session_id}/video/recording")
async def recording_get(session_id, current_user=..., db=...):
row = await _load_session(session_id, current_user, db)
if row.pipeline_id is None:
return {"status": "none"} # nunca se grabó
if not row.s3_key:
key = video_service.find_recording_key(str(row.id)) # escanea el prefijo
if not key:
return {"status": "processing"} # la tubería sigue vaciando
row.s3_key = key
await db.commit()
return {"status": "ready",
"url": video_service.presigned_url(row.s3_key)} # bucket privado
Tres reglas más de grabación que aprendí por la vía un-poco-difícil:
- Detén la grabación antes de borrar la reunión. La tubería necesita la reunión viva para vaciar su fragmento final. Desmantela la reunión primero y truncas el archivo. Tanto el endpoint End como el segador detienen la grabación primero.
- El bucket es privado. Siempre sirve vía URL prefirmada. La grabación de una conversación real es sensible; nunca debe ser un objeto público. Una regla de ciclo de vida de 90 días sobre el prefijo acota el costo de almacenamiento y la retención.
-
Codifica el session id en el prefijo de la llave de S3 (
sessions/{id}/). Es cómo el endpoint de lectura encuentra el archivo que dejó la tubería, y evita que las grabaciones de una sesión se filtren al listado de otra.
Fase 5: consentimiento y auditoría (la pasada de seguridad)
Grabar a otra persona es un problema de consentimiento antes de ser uno técnico. La auditoría que destapó los huecos (recorriendo el OWASP Top 10 contra la función) produjo cuatro arreglos que vale la pena resaltar:
Disparador solo-host. Solo el host puede presionar Record, forzado del lado del servidor, no solo escondido en la UI.
if current_user.id != row.host_id:
raise HTTPException(403, "Only the host can start recording.")
Aviso de consentimiento fuera de banda (A04, diseño inseguro). El
banner del websocket dentro de la llamada no alcanza: falla si el socket se cayó o si el guest está en otro dispositivo. Así que el inicio de la grabación también dispara una notificación + un mensaje directo al guest, de mejor esfuerzo:
await notify(db, to_user_id=row.guest_id,
title="Recording started",
body="The host started recording this session. If you're not "
"comfortable, you can leave the call.")
Rastro de auditoría (A09, fallas de registro). El inicio/detención se escriben en un log de auditoría independiente de las columnas de la base de datos, para que una revisión forense pueda probar quién disparó el pipeline incluso después de que la fila se mute más tarde:
AuditLogger.log(action="RECORDING_START", performed_by=str(current_user.id),
target=str(row.id), details={"pipeline_id": pipeline_id})
Sin bypass de admin en el ingreso (A01, control de acceso roto). Tienta dejar que soporte "se asome" a una llamada. No lo hagas. El chequeo de participante es todo el modelo; una excepción a él es un hoyo de privacidad.
Trampa 2: inicio de grabación idempotente
La misma lección que el ingreso. Un doble clic en Record no debe
engendrar dos tuberías facturando en paralelo:
if row.pipeline_id and row.stopped_at is None:
return {"pipeline_id": row.pipeline_id} # ya está grabando, no-op
El resultado
Tres endpoints, un worker, cuatro columnas, ~180 líneas de backend:
POST /sessions/{id}/video/join → crea-o-reutiliza reunión, acuña asistente
POST /sessions/{id}/video/end → detiene grabación, borra reunión, sella ended_at
GET /sessions/{id}/video/status → ¿habilitado? ¿activo? ¿grabando? (gateo de UI)
POST /sessions/{id}/video/recording/start|stop
GET /sessions/{id}/video/recording → none | processing | ready+url
worker: siega las reuniones más viejas de 60 min, cada 60s
El cliente es el SDK administrado del navegador apuntado a la carga
{meeting, attendee} que regresa join: captura de dispositivos,
renderizado de tiles, silenciar/cámara/compartir-pantalla son llamadas al SDK, no código tuyo. Una bandera de funcionalidad (VIDEO_ENABLED) pone en gris el botón en entornos sin credenciales de nube para que el dev local nunca truene por un cliente faltante.
Lo que NO ayudó
- Echar mano de un SaaS de video. Costo recurrente por asiento para lo que es una reunión y dos tokens.
- WebRTC desde cero. Señalización + TURN + SFU es un montón de trabajo para reimplementar el plano administrado, mal.
- Confiar en el plano de medios como fuente de verdad para "terminado". Recolecta basura en su propio horario. Tu columna de base de datos es el hecho; el plano es limpieza.
- Un tope de asistentes / chequeo solo-host solo en la UI. Cualquier cosa forzada solo en el navegador no está forzada.
Qué sí ayudaría a futuro (en orden de palanca)
-
Webhooks/eventos en lugar de sondear por la grabación. Reemplazar el sondeo de "escanea el prefijo de S3" con un evento de completado del plano de medios quita por completo la carrera de
processing. Compensación: otro consumidor de eventos que correr. -
Chequeo de dispositivos pre-ingreso. Una pantalla de vista previa de cámara/micrófono antes del
joincorta el primer minuto de "no te escucho". Puro trabajo de cliente. - Sala de espera. Detén al segundo asistente hasta que llegue el host. Compensación: un poquito de estado y un empujón por websocket.
- Métrica de costo por llamada. Emite los minutos asistente a tu backend de métricas para que la cuenta sea observable antes de la factura, no después.
Lecciones
- Mide el costo unitario antes de diseñar. $0.0017/minuto-asistente es lo que volvió al interruptor de 60 minutos la función de cabecera, no un adorno.
- La llamada abandonada, no la ruta feliz, es el riesgo de costo.Diseña el worker de desmantelamiento primero; el botón End es el 80% fácil.
- Cada "create" que el cliente puede disparar dos veces tiene que ser idempotente: join, attendee, inicio de grabación. Dos humanos y un doble clic van a encontrar cada ruta no-idempotente.
-
Tu base de datos es la fuente de verdad para el estado; el plano administrado es de mejor esfuerzo. Lee
ended_at, no "¿todavía existe la reunión?". -
Los topes pertenecen a la capa que fuerza, no a la capa que muestra.El tope de 2 asistentes vive en
create_attendee, no en el componente de React. - La grabación es una función de consentimiento. Disparador solo-host, aviso fuera de banda, log de auditoría, antes de escribir un solo byte de la voz de alguien.
-
Correlaciona con tus propios IDs.
ExternalMeetingId/ExternalUserIdpuestos a los IDs de tu fila es lo único que hace depurables los logs del plano.
Top comments (0)