Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Table of Contents:
| **[Deploy Memos app](containers/memos-terraform/)** <br/> A journaling application with its database deployed with Terraform | Terraform | [Terraform] |
| **[Metabase on VPC](containers/vpc-metabase/README.md)** <br/> A Metabase instance running in a private network with a PostgreSQL database. | N/A | [Terraform] |
| **[MongoDB® on VPC](containers/vpc-mongodb/README.md)** <br/> 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)** <br/> A FastAPI container streaming Server-Sent Events to debug proxy buffering and connection timeouts. | Python | [CLI] |

### ⚙️ Jobs

Expand Down
12 changes: 12 additions & 0 deletions containers/python-fastapi-stream/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
69 changes: 69 additions & 0 deletions containers/python-fastapi-stream/README.md
Original file line number Diff line number Diff line change
@@ -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)
156 changes: 156 additions & 0 deletions containers/python-fastapi-stream/app/main.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading