diff --git a/README.md b/README.md index a358b4c..7c3ae73 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Table of Contents: | **[Deploy Memos app](containers/memos-terraform/)**
A journaling application with its database deployed with Terraform | Terraform | [Terraform] | | **[Metabase on VPC](containers/vpc-metabase/README.md)**
A Metabase instance running in a private network with a PostgreSQL database. | N/A | [Terraform] | | **[MongoDB® on VPC](containers/vpc-mongodb/README.md)**
A MongoDB® instance running in a private network with a sample application connecting to it. | TypeScript on Deno | [Terraform] | +| **[FastAPI Streaming (SSE)](containers/python-fastapi-stream/README.md)**
A FastAPI container streaming Server-Sent Events to debug proxy buffering and connection timeouts. | Python | [CLI] | ### ⚙️ Jobs diff --git a/containers/python-fastapi-stream/Dockerfile b/containers/python-fastapi-stream/Dockerfile new file mode 100644 index 0000000..d5af037 --- /dev/null +++ b/containers/python-fastapi-stream/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.14-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8080 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", "--no-access-log"] diff --git a/containers/python-fastapi-stream/README.md b/containers/python-fastapi-stream/README.md new file mode 100644 index 0000000..23f4297 --- /dev/null +++ b/containers/python-fastapi-stream/README.md @@ -0,0 +1,69 @@ +# FastAPI Streaming Example + +A minimal FastAPI container with Server-Sent Events (SSE) streaming responses, designed for debugging network issues on Scaleway Serverless Containers. + +## Endpoints + +| Method | Path | Description | +| --------- | -------------------------- | ---------------------------------------- | +| `GET` | `/` | Browser test page (HTML) | +| `GET` | `/health` | Health check | +| `POST` | `/public/completions` | Stream SSE events (configurable) | +| `POST` | `/public/completions/long` | Long-duration stream for timeout testing | +| `OPTIONS` | `/public/completions` | CORS preflight | + +### `/public/completions` query params + +| Param | Default | Description | +| -------- | ------- | ---------------------------- | +| `chunks` | `10` | Number of SSE events to emit | +| `delay` | `1.0` | Seconds between each event | + +### `/public/completions/long` query params + +| Param | Default | Description | +| ---------- | ------- | ----------------------- | +| `duration` | `300` | Total seconds to stream | +| `interval` | `5` | Seconds between events | + +## Local development + +```bash +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8080 +``` + +## Build & deploy (Scaleway) + +```bash +scw container deploy +``` + +## Testing + +### Browser + +Open `http://localhost:8080/` (or your deployed container URL) in a browser. The test page lets you: + +- Set the number of chunks and delay between events +- Start, stop, and clear streams +- See SSE events and network errors in real time + +### CLI + +```bash +# Quick test +curl -N -X POST http://localhost:8080/public/completions?chunks=5&delay=1 + +# Long stream (test timeouts) +curl -N -X POST http://localhost:8080/public/completions/long?duration=600&interval=5 +``` + +## Streaming headers + +The response includes: + +- `Content-Type: text/event-stream` +- `Cache-Control: no-cache` +- `Connection: keep-alive` +- `X-Accel-Buffering: no` (disables nginx proxy buffering) diff --git a/containers/python-fastapi-stream/app/main.py b/containers/python-fastapi-stream/app/main.py new file mode 100644 index 0000000..119f058 --- /dev/null +++ b/containers/python-fastapi-stream/app/main.py @@ -0,0 +1,156 @@ +"""Minimal FastAPI streaming example for debugging purposes.""" + +import asyncio +import json +import time +import logging +import os + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="FastAPI Streaming Example") + +# Mount static files +_static_dir = os.path.join(os.path.dirname(__file__), "static") +app.mount("/static", StaticFiles(directory=_static_dir), name="static") + +# Allow CORS for browser-based clients +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], +) + +# --- SSE helpers ----------------------------------------------------------- + +def sse_event(data: dict) -> bytes: + """Format a dict as a Server-Sent Event.""" + return f"data: {json.dumps(data)}\n\n".encode("utf-8") + + +# --- Streaming generator ---------------------------------------------------- + +async def event_stream(chunks: int = 10, delay: float = 1.0): + """Yield ``chunks`` SSE events with ``delay`` seconds between each.""" + for i in range(chunks): + payload = { + "id": i, + "content": f"Chunk {i} of {chunks}", + "timestamp": time.time(), + } + logger.info("Sending chunk %d/%d", i + 1, chunks) + yield sse_event(payload) + await asyncio.sleep(delay) + + # Final event + yield sse_event({"done": True, "total_chunks": chunks}) + + +# --- Routes ----------------------------------------------------------------- + +@app.get("/") +async def root(): + """Serve the test HTML page.""" + from fastapi.responses import FileResponse + return FileResponse(os.path.join(_static_dir, "index.html")) + + +@app.get("/health") +async def health(): + return {"status": "healthy"} + + +@app.options("/public/completions") +async def completions_options(): + """Handle CORS preflight for the streaming endpoint.""" + return JSONResponse(content={}, headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }) + + +@app.post("/public/completions") +async def completions(request: Request): + """Stream a configurable number of SSE events. + + Query params: + chunks: number of SSE events to emit (default 10) + delay: seconds between each event (default 1.0) + """ + params = request.query_params + chunks = int(params.get("chunks", 10)) + delay = float(params.get("delay", 1.0)) + + logger.info( + "Starting stream: chunks=%d delay=%.2f client=%s", + chunks, delay, request.client.host if request.client else "unknown", + ) + + return StreamingResponse( + event_stream(chunks=chunks, delay=delay), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # disable nginx buffering + }, + ) + + +# --- Long stream for stress testing ---------------------------------------- + +@app.post("/public/completions/long") +async def completions_long(request: Request): + """Stream events for a long duration to test timeouts. + + Query params: + duration: total seconds to stream (default 300) + interval: seconds between events (default 5) + """ + params = request.query_params + duration = float(params.get("duration", 300)) + interval = float(params.get("interval", 5)) + + async def long_stream(): + start = time.time() + i = 0 + while time.time() - start < duration: + i += 1 + elapsed = time.time() - start + yield sse_event({ + "id": i, + "elapsed": round(elapsed, 2), + "remaining": round(max(0, duration - elapsed), 2), + }) + await asyncio.sleep(interval) + + yield sse_event({"done": True, "total_events": i}) + + logger.info( + "Starting long stream: duration=%.0f interval=%.2f client=%s", + duration, interval, request.client.host if request.client else "unknown", + ) + + return StreamingResponse( + long_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/containers/python-fastapi-stream/app/static/index.html b/containers/python-fastapi-stream/app/static/index.html new file mode 100644 index 0000000..c12e019 --- /dev/null +++ b/containers/python-fastapi-stream/app/static/index.html @@ -0,0 +1,202 @@ + + + + + + FastAPI Streaming Test + + + +

FastAPI Streaming Test

+ +
+ + + + + +
+ +
Ready.
+
+ + + + diff --git a/containers/python-fastapi-stream/requirements.txt b/containers/python-fastapi-stream/requirements.txt new file mode 100644 index 0000000..3fb50f0 --- /dev/null +++ b/containers/python-fastapi-stream/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0