Saltar al contenido
Vacav0.1.0GitHub

HTTP y concurrencia

Vaca incluye un servidor HTTP/1.1 concurrente, un cliente HTTP y un modelo de tareas basado en hilos del sistema. No hay tokio ni frameworks externas: el runtime es el binario.

Servidor HTTP

importar http
importar json

fn manejador(peticion) {
    si peticion.metodo == "GET" y peticion.ruta == "/salud" {
        retornar {"cuerpo": {"estado": "ok"}}
    }

    si peticion.metodo == "POST" y peticion.ruta == "/eco" {
        datos = json.convertir(peticion.cuerpo)
        retornar {"estado": 201, "cuerpo": {"recibido": datos}}
    }

    retornar {"estado": 404, "cuerpo": {"error": "no encontrada"}}
}

http.servidor(8080, manejador)

Firma

http.servidor(puerto, manejador)
http.servidor(host, puerto, manejador)
http.servidor(puerto, manejador, limite)
http.servidor(host, puerto, manejador, limite)

http.servidor_tls(puerto, cert, clave, manejador)
http.servidor_tls(host, puerto, cert, clave, manejador, limite)
  • host por defecto es "0.0.0.0".
  • limite es el número máximo de conexiones simultáneas. 0 o un valor negativo significa ilimitado.
  • Al superar el límite se responde 503 Service Unavailable con Retry-After: 1.
  • El manejador puede ser una función normal o async fn; si es async, el servidor espera la tarea antes de responder.

Petición

El manejador recibe un diccionario con estos campos:

Campo Tipo Descripción
metodo Texto GET, POST, PUT, DELETE, etc.
ruta Texto Ruta decodificada, sin query
consulta Diccionario Parámetros de la query
cabeceras Diccionario Cabeceras con claves en minúsculas
cuerpo Texto Cuerpo completo
version Texto Versión HTTP
ip Texto IP del cliente
nombre = peticion.consulta.obtener("nombre", "invitado")
autorizacion = peticion.cabeceras.obtener("authorization", "")

Respuesta

El manejador puede devolver:

  • Un texto: se envía como 200 text/plain.
  • nulo: 204 No Content.
  • Una lista o diccionario: se serializa a JSON automáticamente.
  • Un diccionario de respuesta con campos opcionales:
Campo Tipo Descripción
estado Entero Código HTTP; por defecto 200
cuerpo Cualquiera Cuerpo de la respuesta
tipo Texto Content-Type
cabeceras Diccionario Cabeceras adicionales
retornar {
    "estado": 201,
    "cuerpo": {"id": 42, "creado": verdadero},
    "tipo": "application/json; charset=utf-8",
    "cabeceras": {"X-Request-Id": "abc-123"}
}

Si devuelves un diccionario de datos directamente, se interpreta como sobre de respuesta (busca estado y cuerpo). Para devolver datos planos, envuélvelos: {"cuerpo": {"ok": verdadero}}.

API completa

importar http
importar json
importar concurrencia

productos = [
    {"id": 1, "nombre": "Teclado", "precio": 30},
    {"id": 2, "nombre": "Monitor", "precio": 180}
]
siguiente_id = concurrencia.contador(2)
peticiones = concurrencia.contador(0)

fn responder(estado, cuerpo) {
    retornar {"estado": estado, "cuerpo": cuerpo}
}

fn manejador(peticion) {
    total = peticiones.incrementar()

    si peticion.metodo == "GET" y peticion.ruta == "/salud" {
        retornar responder(200, {
            "estado": "ok",
            "peticiones": total,
            "productos": productos.longitud()
        })
    }

    si peticion.metodo == "GET" y peticion.ruta == "/productos" {
        nombre = peticion.consulta.obtener("nombre", "").recortar()
        si nombre == "" {
            retornar responder(200, {"productos": productos})
        }
        filtrados = productos.filtrar(fn(p) {
            retornar p.nombre.minusculas().contiene(nombre.minusculas())
        })
        retornar responder(200, {"productos": filtrados})
    }

    si peticion.metodo == "POST" y peticion.ruta == "/productos" {
        intentar {
            datos = json.convertir(peticion.cuerpo)
            si no datos.existe("nombre") {
                retornar responder(400, {"error": "falta nombre"})
            }
            producto = {
                "id": siguiente_id.incrementar(),
                "nombre": datos.obtener("nombre"),
                "precio": decimal(datos.obtener("precio", 0))
            }
            productos.agregar(producto)
            retornar responder(201, {"producto": producto})
        } atrapar error {
            retornar responder(400, {"error": a"JSON inválido: {error}"})
        }
    }

    retornar responder(404, {"error": "ruta no encontrada"})
}

http.servidor("0.0.0.0", 8080, manejador, 1000)

Concurrencia

Tareas async

Una función async se ejecuta en un hilo y devuelve una tarea. esperar bloquea hasta que termina:

importar tiempo

async fn consultar(nombre, ms) {
    tiempo.dormir(ms)
    retornar a"{nombre} lista"
}

a = consultar("A", 200)
b = consultar("B", 200)

resultado_a = esperar a
resultado_b = esperar b
  • Dos tareas lanzadas seguidas se ejecutan a la vez.
  • Esperar dos veces la misma tarea devuelve el resultado cacheado.
  • Un error lanzado dentro de la tarea se propaga al esperar.
  • También existen funciones anónimas async: t = async fn() { ... }.
  • Un método de instancia puede ser async; el constructor iniciar no.

Módulo concurrencia

importar concurrencia

t1 = concurrencia.tarea(procesar, "a")
t2 = concurrencia.tarea(procesar, "b")
resultados = concurrencia.esperar_todas([t1, t2])

# O directamente:
resultados = concurrencia.paralelo([
    fn() { retornar procesar("a") },
    fn() { retornar procesar("b") }
])
Función Descripción
tarea(funcion, ...args) Lanza una función en un hilo
paralelo([funciones]) Lanza todas y devuelve resultados
esperar_todas(lista) Espera todas las tareas
contador(inicial = 0) Contador atómico
hilos() Hilos de CPU disponibles

Estado compartido

Los contadores atómicos evitan perder incrementos:

importar concurrencia

contador = concurrencia.contador(0)

fn trabajar() {
    para _ = 1 hasta 1000 {
        contador.incrementar()
    }
    retornar nulo
}

tareas = []
para _ = 1 hasta 8 {
    tareas.agregar(concurrencia.tarea(trabajar))
}
concurrencia.esperar_todas(tareas)
imprimir(contador.valor())    # 8000

Los diccionarios también ofrecen una operación atómica:

visitas = {}
visitas.incrementar("total")       # devuelve el nuevo valor
visitas.incrementar("total", 10)

Las lecturas y escrituras individuales de listas y diccionarios están protegidas por un mutex, pero una secuencia de varias operaciones no es atómica. Para contadores compartidos usa concurrencia.contador o diccionario.incrementar.

Límites del modelo

  • Cada tarea y cada conexión HTTP crea un hilo con 8 MiB de pila virtual.
  • No hay event loop: miles de tareas simultáneas consumen memoria.
  • No hay canales entre tareas; comparte contadores atómicos o diccionarios.
  • No esperes una tarea desde dentro de sí misma: se bloquearía para siempre.
  • No hay recolector de basura; los ciclos de Arc no se liberan.

Cliente HTTP

importar http

respuesta = http.peticion("http://127.0.0.1:8080/salud")
imprimir(respuesta.estado)
imprimir(respuesta.cabeceras)
imprimir(respuesta.cuerpo)

respuesta = http.peticion(
    "http://api.local/productos",
    "POST",
    "{\"nombre\":\"Cable\"}",
    {"Content-Type": "application/json"}
)
Campo de respuesta Tipo
estado Entero
razon Texto
cabeceras Diccionario
cuerpo Texto

Se admiten http:// y https://. En HTTPS se valida el certificado contra las raíces de Mozilla; VACA_CA añade una CA propia.

Laboratorio de estrés

El ejemplo ejemplos/servidor_concurrente.vaca está pensado para pruebas de carga:

cargo build --release
./target/release/vaca ejemplos/servidor_concurrente.vaca
python3 scripts/probar_concurrencia.py

Rutas:

Ruta Descripción
GET /salud Estado, peticiones y hilos
GET /metricas Peticiones, lentas y errores
GET /lento?ms=200 Duerme ms y responde
POST /eco Devuelve el JSON recibido

El script lanza 50 peticiones de 200 ms a la vez y comprueba que terminan en ~0,2 s, que los contadores atómicos son exactos y que los identificadores de las peticiones concurrentes son únicos.

Medir con Vaca

importar tiempo
importar concurrencia

fn trabajo(id) {
    tiempo.dormir(200)
    retornar id
}

inicio = tiempo.millis()
tareas = []
para i = 1 hasta 50 {
    tareas.agregar(concurrencia.tarea(trabajo, i))
}
resultados = concurrencia.esperar_todas(tareas)
total = tiempo.millis() - inicio

imprimir(a"{resultados.longitud()} tareas en {total} ms")

Apagar el servidor

http.servidor no expone una función de parada, pero puedes terminarlo desde un manejador:

importar sistema

si peticion.ruta == "/detener" {
    sistema.salir(0)
}

La petición que dispara la salida no recibe respuesta: el proceso termina.

Despliegue

systemd

[Unit]
Description=API Vaca
After=network.target

[Service]
ExecStart=/opt/vaca/api.vaca
WorkingDirectory=/opt/vaca
Restart=on-failure
User=vaca

[Install]
WantedBy=multi-user.target

Docker

docker build -f packaging/Dockerfile -t vaca:0.1.0 .
docker run --rm -p 8080:8080 vaca:0.1.0

TLS

Vaca termina TLS de forma nativa con rustls (TLS 1.2 y 1.3):

http.servidor_tls("0.0.0.0", 8443, "servidor.pem", "servidor-clave.pem", manejador, 1000)

Las rutas se resuelven contra el directorio del script. El certificado puede incluir la cadena completa; la clave admite PKCS#8, PKCS#1 y SEC1.

Para probar en local:

openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \
  -keyout servidor-clave.pem -out servidor.pem \
  -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

En producción usa un certificado de una CA real y reinicia el proceso al renovarlo: Vaca no implementa ACME. También puedes poner Nginx o Caddy delante si prefieres que ellos gestionen los certificados.

El cliente acepta https:// y valida contra las raíces de Mozilla. Para una CA interna:

VACA_CA=/ruta/ca.pem vaca cliente.vaca

VACA_CA solo añade una raíz; no desactiva la validación.

Seguridad para un laboratorio

Vaca no es un sandbox. Un programa tiene acceso al sistema de archivos, al entorno y a la red según el usuario que lo ejecute.

  • Ejecuta el servidor en un contenedor o usuario sin privilegios.
  • Limita las conexiones con el cuarto argumento de http.servidor.
  • El servidor limita cabeceras a 64 KiB y cuerpo a 16 MiB, y rechaza Transfer-Encoding. Aun así, valida los tamaños en tu manejador si esperas cargas grandes.
  • json.convertir rechaza JSON inválido y anidamiento excesivo, pero no valida esquemas; valida los campos a mano.
  • No hay autenticación ni rate limiting nativos. Impleméntalos en el manejador con diccionarios y contadores atómicos.
  • Las rutas del módulo archivo no están confinadas a un directorio.
  • Para pruebas de carga, usa el límite de conexiones y monitoriza memoria: cada conexión reserva 8 MiB de pila virtual.