Deploy a FastAPI App
A Web Service in Front of the Model
Lesson 68 introduced FastAPI as a way to put Claude behind a web API. This lesson takes that service to deployment. A deployed FastAPI application has to create its client once, hand each request its own answer or a clear failure, translate internal errors into responses that are safe to show, and report whether it is healthy. Each of those needs a deliberate design, and the deployment itself adds the server that runs the application and the platform that routes traffic to it.
The example is a small question-answering service with two endpoints: one that asks the model, and one that reports health. The lesson covers the lifecycle that creates and closes the client, the request model that validates input, the mapping from SDK errors to HTTP responses, and the way the service is started. The mapping is the part most often done carelessly, because a raw error message can reveal configuration to anyone who receives it.
One Client for the Whole Process
A web application handles many requests, and creating a client for each one wastes connections and configuration. FastAPI's lifespan mechanism creates the client when the application starts, makes it available to every request, and closes it when the application shuts down. The asynchronous client from Lesson 72 is the natural choice, because a web server handles many requests concurrently:
from contextlib import asynccontextmanager
from anthropic import AsyncAnthropic
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.client = AsyncAnthropic(timeout=30.0, max_retries=2)
yield
await app.state.client.close()
app = FastAPI(lifespan=lifespan)
The client is stored on the application's state, so a request handler reaches it through the request. The timeout and retry settings from Lessons 143 and 144 are set here, once, for every request the service makes.
Validating the Request
The question arrives as JSON, and the request model checks it before the handler runs. A length limit bounds the cost of each request, and the validation rules from Lesson 174 are applied by declaring them on the model:
from pydantic import BaseModel, Field
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=2000)
A request that breaks the rules receives a validation error from FastAPI before any model call is made, so invalid input costs nothing.
Mapping Errors to Responses
A failed model call must become an HTTP response, and the response must tell the caller what happened without revealing what the service is. The status code tells the caller whether to retry, and the message should be short and fixed. A raw SDK message can include the key's identity, the account's configuration, or a region name, so it is logged and not returned. The mapping below applies that rule:
"""error_responses.py: map SDK errors to HTTP status codes and messages that are safe to return."""
from anthropic import (
APIConnectionError, APIStatusError, APITimeoutError, AuthenticationError,
BadRequestError, InternalServerError, PermissionDeniedError, RateLimitError,
)
def http_status_for(error: Exception) -> tuple[int, str]:
if isinstance(error, APITimeoutError):
return 504, "The model took too long to answer. Please try again."
if isinstance(error, APIConnectionError):
return 503, "The service is temporarily unavailable. Please try again."
if isinstance(error, RateLimitError):
return 429, "The service is busy. Please wait a moment and try again."
if isinstance(error, (AuthenticationError, PermissionDeniedError)):
return 500, "The service is misconfigured. The operators have been notified."
if isinstance(error, BadRequestError):
return 400, "The request could not be processed."
if isinstance(error, InternalServerError):
return 503, "The service is temporarily unavailable. Please try again."
if isinstance(error, APIStatusError):
return 500, "Something went wrong. Please try again later."
return 500, "Something went wrong. Please try again later."
if __name__ == "__main__":
from api_errors import fake_status_error
import httpx
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
samples = [
APITimeoutError(request=request),
fake_status_error(429, "rate_limit_error", "slow", "req_1"),
fake_status_error(401, "authentication_error", "bad key sk-demo", "req_2"),
fake_status_error(400, "invalid_request_error", "max_tokens too large", "req_3"),
]
for error in samples:
status, message = http_status_for(error)
print(status, message)
Output:
504 The model took too long to answer. Please try again.
429 The service is busy. Please wait a moment and try again.
500 The service is misconfigured. The operators have been notified.
400 The request could not be processed.
How It Works
- Timeouts map to 504, a gateway timeout, which tells the caller the upstream service did not answer in time.
- Connection failures and server errors map to 503, meaning the service is temporarily unavailable, which a caller may retry.
- Rate limits map to 429, and the message asks the caller to wait, which matches the backoff advice from Lesson 146.
- Authentication and permission errors map to 500, with a message that hides the cause. The third sample's error message contains a key-like string, and it is not returned. The caller learns only that the service is misconfigured, while the operators receive the details through logging.
- A bad request maps to 400 without echoing the model's complaint, since the caller's input is the only thing it could correct, and the full message is logged for the developers.
- The final branch is a catch-all, so an unforeseen error still produces a safe response.
The message returned for the third sample never includes the words of the original error, and that is the property the function exists to guarantee.
The Endpoints
The request handler calls the model through the shared client, maps any failure to a response, and returns the answer otherwise. The health endpoint answers without calling the model, so a platform can check that the process is running without spending tokens:
from fastapi import HTTPException
from anthropic import APIError
from error_responses import http_status_for
@app.post("/ask")
async def ask(body: AskRequest):
try:
response = await app.state.client.messages.create(
model="claude-sonnet-5-5",
max_tokens=512,
messages=[{"role": "user", "content": body.question}],
)
except APIError as error:
status, message = http_status_for(error)
raise HTTPException(status_code=status, detail=message)
answer = "".join(block.text for block in response.content if block.type == "text")
return {"answer": answer}
@app.get("/health")
async def health():
return {"status": "ok"}
The handler catches APIError, the base of the SDK's error hierarchy (Lesson 141), so every failure from the client is covered, and the more specific mapping decides the response. The health check depends on nothing external, so a failing model service does not make the process look dead to the platform, which would restart it for no benefit.
Starting the Service
The application is started with an ASGI server, which runs the FastAPI application and handles connections:
uvicorn app.main:app --host 0.0.0.0 --port 8000
The server runs in the container from Lesson 188, and the platform routes traffic to its port. Several server processes can run side by side for capacity. Each one creates its own client during startup, and the rate-limit planning from Lesson 170 must count every process, since they share the account's quota.
Summary
A FastAPI service creates one asynchronous client at startup and closes it at shutdown, with the timeout and retry settings applied once. Requests are validated by a model before any call is made. SDK errors are mapped to status codes and fixed messages that never reveal configuration, while the details are logged. The health endpoint answers without the model, so the platform's checks do not depend on an external service. The next lesson covers how to keep such a service observable once it is running in production.