Deploy a FastAPI AI Service with Auth and Tracing
Wrap a RAG chatbot in a production FastAPI service with API key auth, rate limiting, OpenTelemetry tracing, Redis caching and Docker deployment.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project turns a working RAG chatbot into a production FastAPI service. You add Pydantic validation, streaming responses, API key authentication, per-key rate limiting in Redis, OpenTelemetry tracing across retrieval, reranking and generation, exact and semantic caching, and a Docker image deployed to a managed container platform. By the end, other systems can call your pipeline safely, you can see where every second and every token went, and repeated questions stop costing you money.
A RAG pipeline that runs on your laptop is not yet a service. With no authentication, anyone who finds the URL can query it. With no rate limit, one misbehaving client can burn through your LLM budget in minutes. With no tracing, a slow answer is a mystery: was it retrieval, reranking or generation? With no cache, you pay full price for a question that an hour-ago colleague already asked. Each layer in this project closes one of those gaps.
rate limit]:::tool auth --> cache{Cache hit?}:::tool cache -->|hit| reply([Return answer]):::good cache -->|miss| rag[RAG pipeline
traced spans]:::cloud rag --> store[(Store in Redis)]:::data store --> reply classDef code fill:#DBEAFE,color:#1E293B classDef tool fill:#475569,color:#FFFFFF classDef cloud fill:#8B5CF6,color:#FFFFFF classDef data fill:#F97316,color:#FFFFFF classDef good fill:#10B981,color:#FFFFFF
What you will build
- A FastAPI service with Pydantic request and response models and a streaming endpoint
- API key authentication, per-key rate limiting in Redis, and tenant isolation
- OpenTelemetry traces with one span per pipeline stage, plus token counts per tenant
- An exact-match cache and a semantic cache with a TTL and an index version
- A pinned, non-root Docker image deployed to a managed platform, with a written cost comparison
Prerequisites
You need the pipeline from the Production RAG Chatbot project (or the refund agent from the Reliable AI Agent project). This project wraps that code and does not rebuild it. The snippets assume your pipeline exposes hybrid_search, rerank, generate_answer and a streaming variant generate_answer_streaming, plus a sentence-embedding model called embedder. You also need Docker, Python 3.11 and a Redis instance (a local container is fine).
TipIf you finished both earlier projects, wrap the RAG chatbot. Its token streaming makes the streaming milestone much easier to see and test.
Engineering DecisionUse a managed model API and a managed container platform for almost everything. Self-host inference only when data residency demands it or when your measured API bill at real traffic exceeds the cost of running GPUs. Milestone 5 makes you compute that comparison with your own numbers.
Milestone 1: Serve the Pipeline with FastAPI and Pydantic
This milestone exposes your RAG pipeline as a validated HTTP API with a normal endpoint and a streaming endpoint.
A script works for one person on one laptop. A service needs a fixed request and response contract, validation before the expensive pipeline runs, and concurrent request handling. FastAPI with Pydantic gives you all three, and generates interactive docs at /docs for free.
Create this layout:
mkdir -p rag-service/app && cd rag-servicetouch app/__init__.py app/main.py app/models.py app/pipeline.py requirements.txtCreate app/models.py:
from pydantic import BaseModel, Field class ChatRequest(BaseModel): question: str = Field(..., min_length=3, max_length=500) employee_id: str = Field(..., description="Used for audit logging, not personalisation") class ChatResponse(BaseModel): answer: str sources: list[str] latency_ms: float cache: str = "miss" # "miss", "exact" or "semantic"Input limits on question reject empty and oversized requests at the edge. A bad request then fails in microseconds instead of spending an embedding call and an LLM call on garbage.
Create app/pipeline.py as the single place that calls your existing RAG code. Keeping it separate means the web layer never needs to know how retrieval works:
from your_rag_package import ( hybrid_search, rerank, generate_answer, db_conn, embedder,) def run_pipeline(question: str, tenant: str) -> tuple[str, list[dict]]: candidates = hybrid_search(question, db_conn, top_k=10, tenant=tenant) reranked = rerank(question, candidates, top_n=4) answer = generate_answer(question, reranked) return answer, rerankedNow the first version of the app. Your pipeline functions are blocking, so the endpoint hands them to a thread pool. Calling them directly inside async def would freeze the event loop and serialise every request.
Create app/main.py:
import asyncioimport time from fastapi import FastAPI, HTTPExceptionfrom fastapi.concurrency import run_in_threadpool from app.models import ChatRequest, ChatResponsefrom app.pipeline import run_pipeline app = FastAPI(title="HR Policy Chatbot API")REQUEST_TIMEOUT_S = 25 @app.get("/healthz")def healthz(): return {"status": "ok"} @app.post("/chat", response_model=ChatResponse)async def chat(request: ChatRequest): start = time.perf_counter() try: async with asyncio.timeout(REQUEST_TIMEOUT_S): answer, reranked = await run_in_threadpool( run_pipeline, request.question, "hr_team" ) except TimeoutError: raise HTTPException(status_code=504, detail="Answer timed out") except Exception: raise HTTPException(status_code=500, detail="Failed to generate answer") return ChatResponse( answer=answer, sources=[c["source"] for c in reranked], latency_ms=round((time.perf_counter() - start) * 1000, 2), )Add a streaming endpoint
A three-second silence feels broken even when the service is fine. Streaming tokens as they are produced fixes perceived latency without changing real generation time. The browser-standard format is Server-Sent Events, where each message is data: ... followed by a blank line.
Add to app/main.py:
from fastapi import Requestfrom fastapi.responses import StreamingResponsefrom your_rag_package import generate_answer_streamingfrom app.pipeline import hybrid_search, rerank, db_conn @app.post("/chat/stream")async def chat_stream(request: ChatRequest, http_request: Request): candidates = await run_in_threadpool(hybrid_search, request.question, db_conn, 10, "hr_team") reranked = await run_in_threadpool(rerank, request.question, candidates, 4) async def event_stream(): try: async with asyncio.timeout(REQUEST_TIMEOUT_S): async for token in generate_answer_streaming(request.question, reranked): if await http_request.is_disconnected(): break # stop paying for tokens nobody will read yield f"data: {token}\n\n" yield "event: done\ndata: [DONE]\n\n" except TimeoutError: yield "event: error\ndata: timeout\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream")Common MistakeHaving no timeout and no disconnect check on streaming calls. A client that drops mid-stream, or an LLM call that hangs, keeps the request open and keeps spending tokens for a reader who left. Wrap generation in a timeout and stop when
is_disconnected()is true, as above.
Run and test it
pip install fastapi "uvicorn[standard]" pydanticuvicorn app.main:app --reloadIn a second terminal, compare the two endpoints. The -N flag turns off curl buffering so you can see tokens arrive:
curl -s -X POST http://localhost:8000/chat \ -H "Content-Type: application/json" \ -d '{"question": "How many casual leave days do I get?", "employee_id": "EMP-4021"}' curl -N -X POST http://localhost:8000/chat/stream \ -H "Content-Type: application/json" \ -d '{"question": "How many casual leave days do I get?", "employee_id": "EMP-4021"}'Expected output: the first command prints one JSON body after a pause, and the second prints data: lines one after another as tokens are generated.
TipThe streaming endpoint skips
response_model, so Pydantic does not check what you send. Keep the request model strict, since that is where bad input arrives.
Milestone 2: Add API Key Authentication and Rate Limiting
This milestone makes every request prove who it is, caps how often each key can call you, and keeps each tenant inside its own documents.
An unauthenticated read-only endpoint is a mild risk. An endpoint that can trigger actions, like the refund agent from the earlier project, is a serious one. Authentication answers who is calling. Tenant isolation answers what that caller may see. They are different checks, and a valid key alone must never mean access to every document.
Store key hashes, load them from the environment
Never keep raw keys in source code. Keep only SHA-256 hashes, loaded from an environment variable or a secrets manager. A leaked config then does not reveal usable keys.
Generate a key and its hash:
python -c "import secrets, hashlibkey = 'sk_' + secrets.token_urlsafe(32)print('KEY (give to client):', key)print('HASH (store server-side):', hashlib.sha256(key.encode()).hexdigest())"Put the hashes in API_KEYS_JSON, for example (one line in real use):
{ "HASH_OF_HR_KEY": {"tenant": "hr_team", "rate_limit": 100}, "HASH_OF_SUPPORT_KEY": {"tenant": "support_team", "rate_limit": 50}}Create app/security.py:
import hashlibimport jsonimport osimport timeimport uuid import redisfrom fastapi import HTTPException, Securityfrom fastapi.security import APIKeyHeader api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)API_KEYS = json.loads(os.environ["API_KEYS_JSON"])redis_client = redis.Redis.from_url( os.environ.get("REDIS_URL", "redis://localhost:6379/0"), decode_responses=True) async def verify_api_key(api_key: str | None = Security(api_key_header)) -> dict: if not api_key: raise HTTPException(status_code=401, detail="Missing API key") digest = hashlib.sha256(api_key.encode()).hexdigest() info = API_KEYS.get(digest) if info is None: raise HTTPException(status_code=401, detail="Invalid API key") return {**info, "key_id": digest[:12]} def check_rate_limit(key_id: str, limit_per_minute: int) -> None: """Sliding window in Redis: count this key's requests in the last 60 seconds.""" now = time.time() rl_key = f"rl:{key_id}" pipe = redis_client.pipeline() pipe.zremrangebyscore(rl_key, 0, now - 60) pipe.zadd(rl_key, {f"{now}:{uuid.uuid4().hex}": now}) pipe.zcard(rl_key) pipe.expire(rl_key, 61) _, _, count, _ = pipe.execute() if count > limit_per_minute: raise HTTPException( status_code=429, detail="Rate limit exceeded", headers={"Retry-After": "60"}, )Using auto_error=False lets you return a clean 401 for a missing key. The default behaviour can answer with 403 depending on your FastAPI version, which confuses clients.
The counter lives in Redis, so every server instance behind a load balancer sees the same numbers, and a restart does not reset anyone's window. Requests that get rejected still count toward the window, which gently punishes a client that hammers a 429.
Protect both endpoints and isolate tenants
Update app/main.py so the tenant comes from the key, never from the request body:
from fastapi import Securityfrom app.security import verify_api_key, check_rate_limit @app.post("/chat", response_model=ChatResponse)async def chat(request: ChatRequest, key_info: dict = Security(verify_api_key)): check_rate_limit(key_info["key_id"], key_info["rate_limit"]) tenant = key_info["tenant"] ... # call run_pipeline(request.question, tenant) as beforeApply the same two dependencies to /chat/stream, and pass tenant into hybrid_search so the database filter only returns that tenant's chunks. A support-team key must never retrieve HR-only policy text.
SecurityAdd
.envand any file holding keys to.gitignorebefore your first commit. In deployment, injectAPI_KEYS_JSONfrom a secrets manager. If a key leaks, remove its hash and redeploy, since the hash cannot be turned back into the key.
Common MistakeTreating authentication and tenant isolation as the same thing. A valid key proves identity only. Always filter retrieval and cache lookups by the tenant tied to that key.
Troubleshooting a surprise 429
A client gets 429 after only 3 calls, far below their limit of 100. Check which value you pass into check_rate_limit. If every request accidentally uses one shared string as the key, all clients share a single counter and the limit trips under concurrent traffic. The first argument must be the per-key key_id.
Milestone 3: Add Distributed Tracing Through the Pipeline
This milestone makes each request show up as a timeline with one named span per stage, so you can see where time and tokens go.
A log line saying "request took 4.2 seconds" gives the total and nothing else. Tracing splits one request into nested spans, each with a start, an end and attributes. A tracing UI then draws the request as a timeline, which turns "it is slow somehow" into "reranking took 2.8 seconds because 40 candidates were passed in".
Set up OpenTelemetry
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http \ opentelemetry-instrumentation-fastapiCreate app/tracing.py:
from opentelemetry import tracefrom opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporterfrom opentelemetry.instrumentation.fastapi import FastAPIInstrumentorfrom opentelemetry.sdk.resources import Resourcefrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessor def setup_tracing(app): provider = TracerProvider(resource=Resource.create({"service.name": "rag-chatbot-service"})) # The endpoint comes from OTEL_EXPORTER_OTLP_ENDPOINT, default http://localhost:4318 provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) trace.set_tracer_provider(provider) FastAPIInstrumentor.instrument_app(app)Call setup_tracing(app) once in app/main.py after creating the app. Start a local trace viewer. Pin the image tag once you pick a version:
docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latestWrap each stage in a span
Update app/pipeline.py. Because this function runs in a worker thread and FastAPI already started a request span, your spans nest under it automatically:
import random from opentelemetry import trace tracer = trace.get_tracer("rag_chatbot_service")FAITHFULNESS_SAMPLE_RATE = 0.1 def run_pipeline(question: str, tenant: str): with tracer.start_as_current_span("chat_request") as root: root.set_attribute("tenant", tenant) root.set_attribute("question_length", len(question)) # length only, never the text with tracer.start_as_current_span("hybrid_retrieval") as span: candidates = hybrid_search(question, db_conn, top_k=10, tenant=tenant) span.set_attribute("candidates_found", len(candidates)) with tracer.start_as_current_span("reranking") as span: reranked = rerank(question, candidates, top_n=4) span.set_attribute("top_score", reranked[0]["rerank_score"] if reranked else 0.0) with tracer.start_as_current_span("generation") as span: answer, usage = generate_answer(question, reranked, return_usage=True) span.set_attribute("input_tokens", usage.input_tokens) span.set_attribute("output_tokens", usage.output_tokens) span.set_attribute("tenant", tenant) if random.random() < FAITHFULNESS_SAMPLE_RATE: with tracer.start_as_current_span("faithfulness_check") as span: context = "\n".join(c["content"] for c in reranked) span.set_attribute("faithfulness_verdict", str(score_faithfulness(context, answer))) return answer, rerankedThis snippet assumes your Project 1 generate_answer can return the model's usage object (add a return_usage flag that passes back response.usage) and that score_faithfulness is imported from your evaluation code. Three choices here are deliberate:
- Token counts and
tenantsit on the generation span, so "which tenant caused the cost spike" becomes a trace query instead of a guess. - The faithfulness check runs on a sample of requests, because the judge is itself an LLM call and checking every request would add cost and latency to every answer.
- Spans record
question_length, not the question. Employee questions can contain personal data, and trace backends are widely readable.
Engineering DecisionAttach evaluation scores to traces, not only to an offline report. When one production answer is bad, you can open that exact trace and see its retrieval candidates, rerank score, faithfulness verdict and generation time together.
Common MistakeShipping with no tracing or token logging. When the bill jumps, nobody can say which tenant, endpoint or prompt caused it. Add token counts and tenant to every generation span from the first release.
Guided practice
Send 10 requests with varied question lengths, then open Jaeger at http://localhost:16686. Pick the slowest trace and name the stage that used the most time.
Milestone 4: Add Response and Semantic Caching
This milestone stops you paying full pipeline cost for the same question twice, using an exact-match cache first and a semantic cache for rephrased questions.
Support chatbots see the same handful of questions all day, such as leave days or the work-from-home policy. A cache hit skips embedding, retrieval, reranking and generation entirely.
Exact-match cache with a TTL and an index version
Two details prevent stale answers. The TTL makes entries expire, so a changed policy is not served forever. The index version is part of the key, so re-indexing your documents instantly makes every old cached answer unreachable.
Create app/cache.py:
import hashlibimport jsonimport os import numpy as np from app.security import redis_client INDEX_VERSION = os.environ.get("INDEX_VERSION", "1")TTL_SECONDS = 3600SEMANTIC_THRESHOLD = 0.95SEMANTIC_MAX_ENTRIES = 50 def _exact_key(question: str, tenant: str) -> str: normalized = " ".join(question.lower().split()) digest = hashlib.sha256(normalized.encode()).hexdigest() return f"chat:{tenant}:v{INDEX_VERSION}:{digest}" def get_exact(question: str, tenant: str): cached = redis_client.get(_exact_key(question, tenant)) return json.loads(cached) if cached else None def set_exact(question: str, tenant: str, response: dict): redis_client.setex(_exact_key(question, tenant), TTL_SECONDS, json.dumps(response))Semantic cache for near-duplicate questions
Exact matching misses "how many casual leaves do I get" versus "how many casual leave days are there". A semantic cache compares embeddings of the new question against recent cached ones. With normalised embeddings, a dot product equals cosine similarity.
Add to app/cache.py:
def _sem_key(tenant: str) -> str: return f"sem:{tenant}:v{INDEX_VERSION}" def get_semantic(embedding: np.ndarray, tenant: str): for raw in redis_client.lrange(_sem_key(tenant), 0, SEMANTIC_MAX_ENTRIES - 1): entry = json.loads(raw) if float(np.dot(embedding, np.array(entry["embedding"]))) >= SEMANTIC_THRESHOLD: return entry["response"] return None def set_semantic(embedding: np.ndarray, tenant: str, response: dict): key = _sem_key(tenant) redis_client.lpush(key, json.dumps({"embedding": embedding.tolist(), "response": response})) redis_client.ltrim(key, 0, SEMANTIC_MAX_ENTRIES - 1) redis_client.expire(key, TTL_SECONDS)The threshold is strict on purpose. Set too loose, it returns a cached answer for a question that only sounds similar but needs a different answer. "How many casual leaves do I get" and "How many sick leaves do I get" can score very high on some models and still have different answers.
Common MistakePicking a semantic threshold without testing it. Embedding models differ, so
0.95is a starting point, not a rule. Build a list of paraphrase pairs and a list of near-miss pairs from your own HR policies, score both, and choose the threshold that separates them.
Wire both caches into the endpoint
Update /chat in app/main.py. The order matters: authenticate, rate limit, exact cache, semantic cache, then the pipeline:
from app import cachefrom app.pipeline import embedder @app.post("/chat", response_model=ChatResponse)async def chat(request: ChatRequest, key_info: dict = Security(verify_api_key)): start = time.perf_counter() check_rate_limit(key_info["key_id"], key_info["rate_limit"]) tenant = key_info["tenant"] def elapsed() -> float: return round((time.perf_counter() - start) * 1000, 2) hit = cache.get_exact(request.question, tenant) if hit: return ChatResponse(**hit, latency_ms=elapsed(), cache="exact") embedding = await run_in_threadpool( embedder.encode, request.question, normalize_embeddings=True ) hit = cache.get_semantic(embedding, tenant) if hit: return ChatResponse(**hit, latency_ms=elapsed(), cache="semantic") try: async with asyncio.timeout(REQUEST_TIMEOUT_S): answer, reranked = await run_in_threadpool(run_pipeline, request.question, tenant) except TimeoutError: raise HTTPException(status_code=504, detail="Answer timed out") response = {"answer": answer, "sources": [c["source"] for c in reranked]} cache.set_exact(request.question, tenant, response) cache.set_semantic(embedding, tenant, response) return ChatResponse(**response, latency_ms=elapsed(), cache="miss")The tenant sits in every cache key, so one tenant can never receive another tenant's cached answer. Latency is now measured, not hard-coded, and the cache field tells you which path served each request.
Concept check
In one sentence, explain why the semantic threshold is 0.95 and not 0.80. Think about what happens to correctness when two genuinely different questions are treated as the same.
Milestone 5: Containerise and Deploy to a Managed Platform
This milestone packages the service as a pinned, non-root Docker image and deploys it to a managed container platform, with secrets kept out of the console.
Docker makes the service portable. A managed platform means you do not run servers or GPUs for a workload that does not need them. This lab shows AWS App Runner in ap-south-1 (Mumbai) because it needs the least setup. The same image runs on Google Cloud Run or Azure Container Apps with different deploy commands.
Pin your dependencies
Unpinned versions mean a rebuild next month can pull breaking changes with no code change on your side. Freeze exact versions once everything works:
pip freeze > requirements.txtWrite the Dockerfile
Create Dockerfile. Copying requirements.txt before the code keeps the dependency layer cached when only source files change:
FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt COPY app ./app RUN useradd --create-home appuserUSER appuser EXPOSE 8000CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Create .dockerignore so secrets and junk never enter the image:
.env.git__pycache__*.pycvenvBuild and run locally
docker build -t hr-chatbot-service:1.0.0 . docker run -p 8000:8000 \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ -e API_KEYS_JSON="$API_KEYS_JSON" \ -e REDIS_URL=redis://host.docker.internal:6379/0 \ hr-chatbot-service:1.0.0Test it the same way you will test the deployed copy:
curl -X POST http://localhost:8000/chat \ -H "X-API-Key: YOUR_RAW_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "How many casual leave days do I get?", "employee_id": "EMP-4021"}'Common MistakeTagging images
latestand leaving dependencies unpinned. You cannot tell what is running or roll back to a known build. Tag releases with a version or commit SHA and pinrequirements.txt.
Push the image and store secrets
aws ecr create-repository --repository-name hr-chatbot-service --region ap-south-1 ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)REGISTRY=$ACCOUNT_ID.dkr.ecr.ap-south-1.amazonaws.com aws ecr get-login-password --region ap-south-1 \ | docker login --username AWS --password-stdin $REGISTRY docker tag hr-chatbot-service:1.0.0 $REGISTRY/hr-chatbot-service:1.0.0docker push $REGISTRY/hr-chatbot-service:1.0.0 aws secretsmanager create-secret --name hrbot/anthropic-api-key \ --secret-string "$ANTHROPIC_API_KEY" --region ap-south-1aws secretsmanager create-secret --name hrbot/api-keys-json \ --secret-string "$API_KEYS_JSON" --region ap-south-1aws secretsmanager create-secret --name hrbot/redis-url \ --secret-string "$MANAGED_REDIS_URL" --region ap-south-1Create the service
Create apprunner.json. Secrets are referenced by ARN, so their values never appear in the console or in deployment history:
{ "ServiceName": "hr-chatbot-api", "SourceConfiguration": { "AuthenticationConfiguration": { "AccessRoleArn": "arn:aws:iam::ACCOUNT_ID:role/AppRunnerECRAccessRole" }, "AutoDeploymentsEnabled": false, "ImageRepository": { "ImageIdentifier": "ACCOUNT_ID.dkr.ecr.ap-south-1.amazonaws.com/hr-chatbot-service:1.0.0", "ImageRepositoryType": "ECR", "ImageConfiguration": { "Port": "8000", "RuntimeEnvironmentVariables": { "INDEX_VERSION": "1", "OTEL_EXPORTER_OTLP_ENDPOINT": "https://YOUR_TRACE_BACKEND" }, "RuntimeEnvironmentSecrets": { "ANTHROPIC_API_KEY": "arn:aws:secretsmanager:ap-south-1:ACCOUNT_ID:secret:hrbot/anthropic-api-key-XXXXXX", "API_KEYS_JSON": "arn:aws:secretsmanager:ap-south-1:ACCOUNT_ID:secret:hrbot/api-keys-json-XXXXXX", "REDIS_URL": "arn:aws:secretsmanager:ap-south-1:ACCOUNT_ID:secret:hrbot/redis-url-XXXXXX" } } } }, "InstanceConfiguration": { "Cpu": "1 vCPU", "Memory": "2 GB", "InstanceRoleArn": "arn:aws:iam::ACCOUNT_ID:role/hrbot-instance-role" }, "HealthCheckConfiguration": { "Protocol": "HTTP", "Path": "/healthz" }}aws apprunner create-service --cli-input-json file://apprunner.json --region ap-south-1The instance role needs secretsmanager:GetSecretValue on those three secrets and nothing else. Your Redis must be reachable from the service. A hosted Redis with a TLS URL works for the lab, and ElastiCache needs a VPC connector.
SecurityNever type API keys or database URLs as plain environment variables in a cloud console. Use the platform's secrets manager and reference the secret by name or ARN, so values stay out of console history and deploy logs.
Document the cost difference
Run a simulated 1,000 requests a day for a few days locally and on the managed platform. Record your own measured numbers:
| Item | Local or laptop | Self-managed cloud VM | Managed platform |
|---|---|---|---|
| Compute cost | Fill in | Fill in | Fill in |
| LLM API cost (from trace token counts) | Fill in | Fill in | Fill in |
| Cache hit rate | Fill in | Fill in | Fill in |
| Operations time per week | Fill in | Fill in | Fill in |
Use your traces for the LLM line: sum input_tokens and output_tokens per tenant and multiply by your provider's current prices. Write a one-page report that says which option you would choose at this traffic and what traffic or constraint would change your answer.
Capstone lab checkpoint
Deploy the container, run the same curl test against the deployed URL, and confirm the response and status codes match local behaviour.
Validation & Testing
These checks confirm each layer works and fails safely. Replace BASE with http://localhost:8000 locally or your deployed URL.
1. Authentication
curl -s -o /dev/null -w "%{http_code}\n" -X POST $BASE/chat \ -H "Content-Type: application/json" \ -d '{"question": "test question", "employee_id": "EMP-1"}'Expected output: 401. Repeat with a wrong key and expect 401 again. A correct key returns 200.
2. Input validation
curl -s -o /dev/null -w "%{http_code}\n" -X POST $BASE/chat \ -H "X-API-Key: YOUR_RAW_KEY" -H "Content-Type: application/json" \ -d '{"question": "hi", "employee_id": "EMP-1"}'Expected output: 422, with no embedding or LLM call made.
3. Rate limiting and key isolation
for i in $(seq 1 60); do curl -s -o /dev/null -w "%{http_code} " -X POST $BASE/chat \ -H "X-API-Key: SUPPORT_KEY" -H "Content-Type: application/json" \ -d "{\"question\": \"rate test number $i\", \"employee_id\": \"EMP-1\"}"done; echoExpected output: with the support key limited to 50 per minute, the first 50 return 200 and the rest return 429. A request with the HR key in the same minute still returns 200.
4. Tenant isolation
Ask a question that only HR documents can answer using the support key.
Expected output: the answer does not contain HR-only content, and the sources list holds only support-team documents.
5. Tracing
Send one uncached request and open its trace in your viewer.
Expected output: spans hybrid_retrieval, reranking and generation nest under the request, and their durations add up to roughly the total latency. The generation span shows input_tokens, output_tokens and tenant.
6. Caching
Send the same question twice, then a rephrased version.
Expected output: the first response shows "cache": "miss" with a normal latency. The identical repeat shows "cache": "exact" with a far lower latency_ms. The rephrase shows "cache": "semantic" only if its similarity clears your threshold. Also confirm a near-miss question (different leave type) shows "miss".
7. Streaming
curl -N -X POST $BASE/chat/stream \ -H "X-API-Key: YOUR_RAW_KEY" -H "Content-Type: application/json" \ -d '{"question": "Explain the work from home policy", "employee_id": "EMP-1"}'Expected output: data: lines appear gradually and the stream ends with a done event. Press Ctrl+C mid-stream and confirm the server logs stop generating.
8. Container
docker run --rm -p 8000:8000 --env-file .env hr-chatbot-service:1.0.0curl -s http://localhost:8000/healthzExpected output: {"status":"ok"}, and http://localhost:8000/docs loads from inside the container.
Quick reference
| Layer | Purpose | Symptom if missing |
|---|---|---|
| Pydantic validation | Reject bad input early | Garbage reaches the expensive pipeline |
| API key auth | Know who is calling | Anyone can spend your budget |
| Redis rate limit | Stop abuse across instances | One client exhausts shared capacity |
| Tenant isolation | Limit what each caller sees | A valid key reads every document |
| Tracing | Locate slowness and cost | "It is slow somehow" with no fix path |
| Caching with TTL and version | Cut repeated cost safely | Full price for the same question, or stale answers |
| Timeouts and disconnect checks | Free resources from dead requests | Hung requests and wasted tokens |
Portfolio deliverable
Keep the repository, a screenshot of one trace with all three spans, your threshold test results, and the one-page cost report. Record your measured cache hit rate and cost figures, since they depend on your traffic and prices.