Build an AI Support Triage API

Build a tested FastAPI service that safely triages support tickets with AI.

Introduction

30 Second Summary

Support queues fill up quickly when every ticket needs a person to decide where it belongs. Automation only helps when its answers stay predictable during bad data or service outages.

In this project, you will build a local FastAPI endpoint that turns a support ticket into a category, urgency, and summary. Typed Pydantic contracts will keep both client input and provider output inside a dependable boundary.

What You'll Build

You will submit support tickets through interactive API documentation to watch valid tickets return structured results while malformed data and outages become controlled responses.

By the end of this project, you'll have:

  • A typed triage result that classifies billing, account, bug, and general tickets with an urgency and summary.
  • Reliable validation and failure handling that rejects undersized requests with a 422 response while mapping provider problems to controlled 502 and 503 responses.
  • A four-test contract suite that proves successful requests, client validation, malformed provider output, and provider outages behave as expected.
  • Secret Mission: Add a retry wrapper that recovers when the provider fails once while preserving the permanent outage response.

Are there any prerequisites?

You need Python 3.10 or newer, Git, and Visual Studio Code on a Mac.

No model API account is required because the project runs locally with a deterministic fake provider at no cost.

Before We Start

Before any code exists, commit to the typed API contract that makes this support-triage service trustworthy. Its guarantee is precise: valid provider data becomes a typed triage result, while invalid data or an unavailable provider produces a controlled failure.

Prepare Your Python Workspace

A dependable backend starts with a runtime another developer can recreate. Your support API therefore needs a verified Python version before any application code exists.

A project-local virtual environment isolates this project's packages from packages elsewhere on your Mac. By the end of this step, Visual Studio Code will use that environment for every project command.

In this step, get ready to:
  • Verify that your Mac runs Python 3.10 or newer.
  • Create the ai-triage-api workspace with its project folders.
  • Install every pinned dependency inside a workspace-local .venv environment.
Check your Python version

The pinned packages require Python 3.10 or newer. A version check confirms whether your existing installation can support the complete workspace.

  • Press Cmd+Space to open macOS search.
  • Type Terminal.
  • Press Enter to open Terminal.
  • Check your installed Python version by running this command:
python3 --version

What Does This Command Do?

The command asks the system Python executable to report its version. That result determines whether you can continue with the existing installation.

✔️ I see the supported version

Good start. Python 3.10 or newer can run every pinned package in this project.

  • Continue to the workspace setup below.

ⓧ I see an older version

Your current Python version is below the packages' compatibility floor. Install Python 3.15.2 before creating the environment.

The installation must finish before a new Terminal session can discover the updated Python executable.

  • Quit Terminal.
  • Press Cmd+Space to open macOS search.
  • Type Terminal.
  • Press Enter to reopen Terminal.
  • Repeat the version check shown above.

ⓧ Command not found

Your shell cannot find a Python 3 installation. The current macOS installer adds the supported runtime needed by this workspace.

A fresh Terminal session lets your shell find the newly installed executable.

  • Quit Terminal.
  • Press Cmd+Space to open macOS search.
  • Type Terminal.
  • Press Enter to reopen Terminal.
  • Repeat the version check shown above.

Still Seeing an Older Version?

Confirm that the Python 3.15.2 installer completed successfully. Make sure you started a fresh Terminal session after the installation.

Ask for targeted help with diagnosing your Python version.

Create the workspace and environment

A VS Code workspace keeps the API code inside one named folder. Its integrated terminal starts from that folder, which keeps every later command pointed at the same project.

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code.
  • Press Enter to open Visual Studio Code.

Visual Studio Code opens without a project folder. The next actions turn a new Desktop folder into the workspace for this API.

Opening your own new folder may trigger the Workspace Trust prompt. You control every file in this empty workspace, so it is safe to trust.

  • Select File in the macOS menu bar.
  • Select Open Folder....
  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Enter ai-triage-api as the folder name.
  • Select Open.
  • Select Yes, I trust the authors if the Workspace Trust prompt appears.

The Explorer now shows ai-triage-api as the open workspace. This folder becomes the fixed home for the API.

  • Select New Folder... in the Explorer.
  • Enter app as the folder name.
  • Select New Folder... in the Explorer again.
  • Enter tests as the folder name.

You should see the app folder beside the tests folder in the Explorer. The workspace now has separate homes for application code and request-level tests.

  • Select View in the macOS menu bar.
  • Select Terminal.

The integrated terminal opens inside ai-triage-api. Commands run here now affect this workspace.

  • Create the workspace-local environment by running this command:
python3 -m venv .venv

What Does This Command Do?

Python creates an isolated interpreter inside .venv. Packages installed through that interpreter stay scoped to this workspace.

The .venv folder should now appear in the Explorer. Your project has its own package boundary.

  • Activate the environment in the integrated terminal by running this command:
source .venv/bin/activate

What Does This Command Do?

The activation script points python and package installation commands at the interpreter inside .venv. The terminal keeps using that interpreter until the environment is deactivated or the terminal closes.

You should see (.venv) at the beginning of the terminal prompt. That prompt confirms the project environment is active.

  • Press Cmd+Shift+P to open the Command Palette.
  • Enter Python: Select Interpreter.
  • Select the interpreter whose path ends with .venv/bin/python.

The VS Code status bar should now identify the workspace-local interpreter. New Python terminals in this workspace use the same environment.

The Environment Is Not Active?

Confirm that the integrated terminal path ends with ai-triage-api. Check that the .venv folder exists in the Explorer.

Ask for help with creating or activating the virtual environment.

Install the pinned dependencies

The requirements.txt file records the exact package releases used by this project. Pinning those releases gives another developer the same dependency set.

  • Select New File... in the Explorer.
  • Enter requirements.txt as the file name.
  • Add the pinned dependencies by pasting this file content:
fastapi==0.162.0
httpx==0.28.1
pydantic==2.14.2
pytest==9.2.0
uvicorn[standard]==0.54.0

What Do These Dependencies Provide?

  • FastAPI 0.162.0 provides the typed HTTP API framework.
  • HTTPX 0.28.1 provides the request client used by FastAPI's test client.
  • Pydantic 2.14.2 validates request data and provider results.
  • pytest 9.2.0 runs the automated contract tests.
  • Uvicorn 0.54.0 runs the API as a local development server.
  • Save requirements.txt.
  • Install the pinned dependencies into the active environment by running this command:
python -m pip install -r requirements.txt

What Does This Command Do?

The active Python interpreter runs pip against requirements.txt. Each exact pin is installed inside the workspace-local environment.

The terminal should finish without an installation failure. Your active .venv now contains the complete project toolchain.

Dependency Installation Failed?

Confirm that requirements.txt is saved directly inside ai-triage-api. Check that the terminal prompt begins with (.venv).

Ask for targeted help with diagnosing the package installation.

✔️ Awesome, I've got everything!

Great. Double-check that requirements.txt is saved before the final import test.

ⓧ I'd like to double check the full code

fastapi==0.162.0
httpx==0.28.1
pydantic==2.14.2
pytest==9.2.0
uvicorn[standard]==0.54.0

What Are You Checking?

Compare each package name and version with your saved file. Exact matches keep the workspace on the tested dependency set.

Before you run the final check, which five package imports do you expect the active environment to load successfully?

  • Verify the complete dependency set by running this command in the integrated terminal:
python -c "import fastapi, httpx, pydantic, pytest, uvicorn; print('Dependencies ready')"

What Does This Command Prove?

Python imports each installed package through the active environment. The confirmation text prints only after every import succeeds.

You should see Dependencies ready in the terminal. That's the workspace foundation complete: your interpreter and pinned dependencies are ready for API code.

The Final Check Did Not Pass?

Confirm that the terminal prompt begins with (.venv). Rerun the dependency installation if the failure mentions a missing package.

Ask for help with finding the failed dependency import.

Your reproducible Python workspace is ready. Next, you will use it to build the first typed triage endpoint and test how it behaves when provider data breaks its contract.

Build and Break the First Endpoint

Your reproducible workspace is ready. A happy-path dictionary can still hide an uncontrolled server error.

A FastAPI response model checks outgoing data at the HTTP boundary. You will see a valid triage response before deliberately breaking the same endpoint with malformed output.

In this step, get ready to:
  • Define typed API contracts for both directions.
  • Build a response-validated triage route.
  • Run one valid request before exposing the malformed-output failure.
Define the typed API contracts

Pydantic models make input limits executable. Literal aliases keep every triage result inside the categories your API promises.

  • In the VS Code file sidebar, select the app folder.
  • Use the new-file control beside app to create __init__.py.
  • Mark the folder as the application package by adding this code:
"""AI support triage application package."""

What does this file do?

The __init__.py file makes app a Python package. Its docstring records the package's purpose.

  • Save app/__init__.py.

You should see __init__.py inside the app folder in the file sidebar.

Cannot find the package file?

  • Check that __init__.py sits directly inside the existing app folder.
  • Check that the filename contains two underscores before init.

Ask for help with checking the application package structure.

✔️ Awesome, I've got everything!

Your application package marker is saved in the correct folder.

ⓧ I'd like to double check the full code

"""AI support triage application package."""

The request model protects the endpoint from undersized tickets. The result model defines the only output shape clients can trust.

  • Select the app folder in the VS Code file sidebar.
  • Use the new-file control beside app to create models.py.
  • Define the request constraints plus the response contract by adding this code:
from typing import Literal

from pydantic import BaseModel, Field

Category = Literal["billing", "bug", "account", "general"]
Urgency = Literal["low", "medium", "high"]


class TicketRequest(BaseModel):
    subject: str = Field(min_length=3, max_length=100)
    message: str = Field(min_length=10, max_length=1000)


class TriageResult(BaseModel):
    category: Category
    urgency: Urgency
    summary: str = Field(min_length=1, max_length=160)

What do these models protect?

  • Category limits results to four supported ticket categories.
  • Urgency limits results to three supported priority levels.
  • TicketRequest rejects subjects or messages outside the declared length limits.
  • TriageResult defines the category, urgency, and summary returned to clients.
  • Save app/models.py.

You should see models.py beneath __init__.py in the app folder. VS Code should show no unresolved imports in the file.

Seeing model errors?

  • Check that models.py is inside the app folder.
  • Compare the capitalization of BaseModel, Field, and Literal with the code above.
  • Confirm that VS Code still uses the workspace-local .venv interpreter.

Ask for help with diagnosing the model definitions.

✔️ Awesome, I've got everything!

Your request and response contracts are saved.

ⓧ I'd like to double check the full code

from typing import Literal

from pydantic import BaseModel, Field

Category = Literal["billing", "bug", "account", "general"]
Urgency = Literal["low", "medium", "high"]


class TicketRequest(BaseModel):
    subject: str = Field(min_length=3, max_length=100)
    message: str = Field(min_length=10, max_length=1000)


class TriageResult(BaseModel):
    category: Category
    urgency: Urgency
    summary: str = Field(min_length=1, max_length=160)
Build the naive triage route

The first route uses the typed contracts without a separate provider boundary. Its deliberate malformed branch lets you observe what happens when outgoing data violates the response model.

  • Select the ai-triage-api workspace at the top of the VS Code file sidebar.
  • Use the workspace-level new-file control to create main.py beside requirements.txt.
  • Create the application plus its first triage route by adding this code:
from fastapi import FastAPI

from app.models import TicketRequest, TriageResult

app = FastAPI(title="AI Support Triage API")


@app.post("/triage", response_model=TriageResult)
def triage_ticket(ticket: TicketRequest) -> dict[str, str]:
    text = f"{ticket.subject} {ticket.message}".lower()

    if "malformed" in text:
        return {"category": "unknown", "urgency": "urgent", "summary": ""}

    return {
        "category": "billing",
        "urgency": "low",
        "summary": f"{ticket.subject}: {ticket.message}"[:160],
    }

What does this route do?

  • FastAPI creates the local API application.
  • @app.post("/triage", response_model=TriageResult) exposes the route with response validation.
  • triage_ticket() combines the subject with the message for case-insensitive matching.
  • The malformed branch returns values that violate every field in TriageResult.
  • The normal branch returns a billing result with a summary capped at 160 characters.
  • Save main.py.

The file sidebar should list main.py beside requirements.txt. VS Code should show no unresolved imports in the route.

Seeing route errors?

  • Check that main.py sits at the top level of ai-triage-api.
  • Confirm that the import path reads from app.models import TicketRequest, TriageResult.
  • Check the indentation inside triage_ticket() against the code above.

Ask for help with diagnosing the first triage route.

✔️ Awesome, I've got everything!

Your first response-validated route is saved.

ⓧ I'd like to double check the full code

from fastapi import FastAPI

from app.models import TicketRequest, TriageResult

app = FastAPI(title="AI Support Triage API")


@app.post("/triage", response_model=TriageResult)
def triage_ticket(ticket: TicketRequest) -> dict[str, str]:
    text = f"{ticket.subject} {ticket.message}".lower()

    if "malformed" in text:
        return {"category": "unknown", "urgency": "urgent", "summary": ""}

    return {
        "category": "billing",
        "urgency": "low",
        "summary": f"{ticket.subject}: {ticket.message}"[:160],
    }
Expose the response boundary

Uvicorn serves the application locally. The server process stays attached to the project terminal while it runs.

  • Start the local development server by running this command in the project terminal:
uvicorn main:app --reload --port 5000

What does this command do?

The main:app value points Uvicorn to the application in main.py. The --reload flag watches local code changes.

The --port 5000 option makes the API available on local port 5000.

The terminal should report that the server is running at http://127.0.0.1:5000.

Server did not start?

  • Confirm that the terminal prompt shows the active .venv environment.
  • Confirm that the terminal is inside the ai-triage-api workspace.
  • Check that main.py contains the variable named app.

Ask for help with diagnosing the Uvicorn startup failure.

  • Leave the server running in the project terminal.
  • Open http://127.0.0.1:5000/docs in your browser.
  • Expand POST /triage in the interactive documentation.
  • Enable the request editor.
  • Set subject to billing.
  • Set message to AI Support Triage API.

Before you submit, do you expect this request to satisfy the response model?

  • Submit the billing request.

You should see status 200 with category set to billing. The summary reads billing: AI Support Triage API.

That first request works. Your endpoint has returned a typed triage result through the interactive documentation.

  • Return to the request editor for POST /triage.
  • Replace the subject value with malformed.

Before you submit, do you think the response model will accept the endpoint's dictionary?

  • Submit the malformed request.

You should see status 500 in the interactive documentation. This failure is intentional.

  • Switch back to the project terminal to inspect the response-validation traceback.

The traceback shows that the route returned data outside the declared contract. The malformed branch has exposed the uncontrolled server error.

Why does the 500 matter?

The route trusts its fake provider data until the response model checks it. Invalid output reaches the HTTP layer as an uncontrolled failure.

You now have visible evidence that provider output needs its own validation boundary.

You found the hidden reliability gap on purpose. Next, you will isolate provider behavior and turn this uncontrolled failure into stable API responses.

Add Provider Boundaries

The malformed ticket exposed an uncontrolled 500 response. That failure proved that FastAPI needs protection from untrusted provider output.

This step moves classification behind a provider boundary. A service layer uses Pydantic to validate every result.

Dependency injection connects the service to the endpoint. Exception handlers give each provider failure a stable HTTP response.

In this step, get ready to:
  • Move ticket classification behind a provider contract.
  • Validate raw provider output inside a service layer.
  • Map each provider failure to its own controlled HTTP response.
Create the provider boundary

A provider boundary gives the application one contract for classification. The fake implementation produces predictable behavior without model credentials.

Why Use a Fake Provider?

A deterministic fake provider returns repeatable results for the same ticket. This keeps the project focused on reliable backend boundaries.

A future model adapter can implement the same TriageProvider contract. The service can keep the same validation flow.

  • Select the app folder in the VS Code file sidebar.
  • Create provider.py inside app with the file creation control.

You should see provider.py beneath models.py in the app folder.

  • Add the provider contract to app/provider.py by pasting the code below:
from typing import Protocol

from .models import TicketRequest


class ProviderUnavailableError(Exception):
    """Raised when the simulated AI provider cannot answer."""


class TriageProvider(Protocol):
    def classify(self, ticket: TicketRequest) -> dict[str, object]: ...

What Does the Provider Contract Do?

  • ProviderUnavailableError gives simulated outages a dedicated exception type.
  • TriageProvider defines the behavior expected from every provider adapter.
  • Protocol lets the service accept compatible providers without depending on one implementation.
  • Save app/provider.py.
  • Confirm the saved file remains listed beneath the app folder.

Cannot Resolve the Model Import?

Confirm that provider.py sits beside models.py. Check that the project import starts with a dot.

Ask for targeted help with diagnosing the provider import.

The contract now needs an implementation. FakeTriageProvider classifies tickets through fixed keyword rules.

  • Append the fake provider below TriageProvider in app/provider.py by pasting this code:
class FakeTriageProvider:
    def classify(self, ticket: TicketRequest) -> dict[str, object]:
        text = f"{ticket.subject} {ticket.message}".lower()

        if "provider-down" in text:
            raise ProviderUnavailableError("AI provider temporarily unavailable")

        if "malformed" in text:
            return {"category": "unknown", "urgency": "urgent", "summary": ""}

        if "invoice" in text or "refund" in text or "charged" in text:
            category = "billing"
        elif "login" in text or "password" in text or "account" in text:
            category = "account"
        elif "crash" in text or "error" in text or "broken" in text:
            category = "bug"
        else:
            category = "general"

        urgency = "high" if "urgent" in text or "blocked" in text else "low"
        summary = f"{ticket.subject}: {ticket.message}"[:160]

        return {
            "category": category,
            "urgency": urgency,
            "summary": summary,
        }

How Does the Fake Provider Behave?

  • The combined ticket text becomes lowercase so every keyword check is consistent.
  • The provider-down trigger raises ProviderUnavailableError.
  • The malformed trigger returns data that violates TriageResult.
  • Normal ticket keywords select a deterministic support category.
  • The ticket language selects an urgency value.
  • The ticket content becomes a bounded summary.
  • Save app/provider.py.
  • Confirm that FakeTriageProvider ends with the returned result dictionary.

Good progress. Your application now has a replaceable provider boundary with repeatable success paths.

Provider Class Shows a Syntax Problem?

Check that FakeTriageProvider starts after the protocol definition. Confirm that each classification branch stays inside classify().

Ask for help with checking the provider structure.

✔️ Awesome, I've got everything!

Your provider contract is complete. Its fake implementation can simulate valid results plus two failure modes.

ⓧ I'd like to double check the full code

from typing import Protocol

from .models import TicketRequest


class ProviderUnavailableError(Exception):
    """Raised when the simulated AI provider cannot answer."""


class TriageProvider(Protocol):
    def classify(self, ticket: TicketRequest) -> dict[str, object]: ...


class FakeTriageProvider:
    def classify(self, ticket: TicketRequest) -> dict[str, object]:
        text = f"{ticket.subject} {ticket.message}".lower()

        if "provider-down" in text:
            raise ProviderUnavailableError("AI provider temporarily unavailable")

        if "malformed" in text:
            return {"category": "unknown", "urgency": "urgent", "summary": ""}

        if "invoice" in text or "refund" in text or "charged" in text:
            category = "billing"
        elif "login" in text or "password" in text or "account" in text:
            category = "account"
        elif "crash" in text or "error" in text or "broken" in text:
            category = "bug"
        else:
            category = "general"

        urgency = "high" if "urgent" in text or "blocked" in text else "low"
        summary = f"{ticket.subject}: {ticket.message}"[:160]

        return {
            "category": category,
            "urgency": urgency,
            "summary": summary,
        }
Validate provider output in a service

Provider dictionaries cross an untrusted boundary. TriageService validates that data before the endpoint returns it.

  • Select the app folder in the VS Code file sidebar.
  • Create service.py inside app with the file creation control.

You should see service.py beside provider.py.

  • Add the service boundary to app/service.py by pasting the code below:
from pydantic import ValidationError

from .models import TicketRequest, TriageResult
from .provider import TriageProvider


class InvalidProviderResponseError(Exception):
    """Raised when provider output violates the API contract."""


class TriageService:
    def __init__(self, provider: TriageProvider) -> None:
        self.provider = provider

    def triage(self, ticket: TicketRequest) -> TriageResult:
        raw_result = self.provider.classify(ticket)

        try:
            return TriageResult.model_validate(raw_result)
        except ValidationError as exc:
            raise InvalidProviderResponseError(
                "AI provider returned invalid data"
            ) from exc

What Does the Service Protect?

  • TriageService accepts any provider that follows the TriageProvider contract.
  • model_validate() checks the raw dictionary against every TriageResult constraint.
  • ValidationError signals that the provider returned an invalid result.
  • InvalidProviderResponseError gives the HTTP layer one stable application error to handle.
  • Save app/service.py.
  • Confirm that the file ends with the chained InvalidProviderResponseError.

Service Imports Are Underlined?

Confirm that service.py sits inside app. Check that both project imports start with a dot.

Ask for targeted help with diagnosing the service imports.

✔️ Awesome, I've got everything!

Your service now validates every provider result before the HTTP layer receives it.

ⓧ I'd like to double check the full code

from pydantic import ValidationError

from .models import TicketRequest, TriageResult
from .provider import TriageProvider


class InvalidProviderResponseError(Exception):
    """Raised when provider output violates the API contract."""


class TriageService:
    def __init__(self, provider: TriageProvider) -> None:
        self.provider = provider

    def triage(self, ticket: TicketRequest) -> TriageResult:
        raw_result = self.provider.classify(ticket)

        try:
            return TriageResult.model_validate(raw_result)
        except ValidationError as exc:
            raise InvalidProviderResponseError(
                "AI provider returned invalid data"
            ) from exc
Map failures to stable HTTP responses

The HTTP layer now needs to construct the service. It also needs to translate application errors into controlled JSON responses.

You will rebuild main.py in small runnable chunks. The Uvicorn reload process provides a quick check after each save.

  • Select main.py in the VS Code file sidebar.
  • Replace its current contents with the application setup below:
from typing import Annotated

from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse

from app.models import TicketRequest, TriageResult
from app.provider import FakeTriageProvider, ProviderUnavailableError
from app.service import InvalidProviderResponseError, TriageService

app = FastAPI(title="AI Support Triage API")


def get_triage_service() -> TriageService:
    return TriageService(FakeTriageProvider())

How Does the Service Factory Help?

  • Annotated supports the endpoint dependency declaration added later.
  • get_triage_service() constructs the fake provider inside the service.
  • The factory gives a future provider adapter one clear replacement point.
  • Save main.py.
  • Switch back to the Uvicorn terminal from earlier.

You should see the development server reload without a traceback. That reload proves the new provider module can be imported by the HTTP layer.

Server Cannot Import the New Layers?

Confirm that provider.py is saved inside app. Confirm that service.py is saved in the same folder.

Ask for help with tracing the failed application import.

Malformed output now becomes InvalidProviderResponseError inside the service. The first exception handler maps that error to 502.

  • Append the invalid-provider handler below get_triage_service() in main.py by pasting this code:
@app.exception_handler(InvalidProviderResponseError)
async def invalid_provider_response_handler(
    _request: Request,
    _exc: InvalidProviderResponseError,
) -> JSONResponse:
    return JSONResponse(
        status_code=502,
        content={"detail": "AI provider returned invalid data"},
    )

What Does the Invalid-Output Handler Do?

The decorator connects InvalidProviderResponseError to one public response. Clients receive 502 with a stable detail message.

  • Save main.py.
  • Switch back to the Uvicorn terminal from earlier.

You should see another reload without a traceback. The invalid-output handler now loads with the application.

Invalid-Output Handler Does Not Load?

Confirm that the decorator starts at the left edge of main.py. Check that the function returns JSONResponse.

Ask for help with checking the invalid-output handler.

A provider outage has a different meaning for clients. Its handler maps ProviderUnavailableError to 503.

  • Append the outage handler below invalid_provider_response_handler() in main.py by pasting this code:
@app.exception_handler(ProviderUnavailableError)
async def provider_unavailable_handler(
    _request: Request,
    _exc: ProviderUnavailableError,
) -> JSONResponse:
    return JSONResponse(
        status_code=503,
        content={"detail": "AI provider temporarily unavailable"},
    )

What Does the Outage Handler Do?

The handler exposes temporary provider unavailability as 503. Its response avoids leaking an internal traceback.

  • Save main.py.
  • Switch back to the Uvicorn terminal from earlier.

You should see another successful reload. Both provider failure types now have public response mappings.

Outage Handler Does Not Load?

Confirm that ProviderUnavailableError is imported from app.provider. Check that the decorator sits directly above provider_unavailable_handler().

Ask for help with checking the outage handler.

The endpoint now delegates classification to TriageService. Its declared result type preserves the successful response contract.

  • Append the final endpoint below provider_unavailable_handler() in main.py by pasting this code:
@app.post("/triage", response_model=TriageResult)
def triage_ticket(
    ticket: TicketRequest,
    service: Annotated[TriageService, Depends(get_triage_service)],
) -> TriageResult:
    return service.triage(ticket)

What Changed at the Endpoint?

  • Depends(get_triage_service) asks FastAPI to provide the service.
  • triage_ticket() passes the validated request to that service.
  • TriageResult remains the successful response contract.
  • Save main.py.
  • Switch back to the Uvicorn terminal from earlier.

You should see a successful reload with /triage available again. The HTTP layer now delegates through the service boundary.

Endpoint Does Not Return After Reload?

Confirm that triage_ticket() appears after both exception handlers. Check that its return statement calls service.triage(ticket).

Ask for help with diagnosing the injected endpoint.

✔️ Awesome, I've got everything!

Your HTTP layer now injects the service. It also maps both provider failure types.

ⓧ I'd like to double check the full code

from typing import Annotated

from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse

from app.models import TicketRequest, TriageResult
from app.provider import FakeTriageProvider, ProviderUnavailableError
from app.service import InvalidProviderResponseError, TriageService

app = FastAPI(title="AI Support Triage API")


def get_triage_service() -> TriageService:
    return TriageService(FakeTriageProvider())


@app.exception_handler(InvalidProviderResponseError)
async def invalid_provider_response_handler(
    _request: Request,
    _exc: InvalidProviderResponseError,
) -> JSONResponse:
    return JSONResponse(
        status_code=502,
        content={"detail": "AI provider returned invalid data"},
    )


@app.exception_handler(ProviderUnavailableError)
async def provider_unavailable_handler(
    _request: Request,
    _exc: ProviderUnavailableError,
) -> JSONResponse:
    return JSONResponse(
        status_code=503,
        content={"detail": "AI provider temporarily unavailable"},
    )


@app.post("/triage", response_model=TriageResult)
def triage_ticket(
    ticket: TicketRequest,
    service: Annotated[TriageService, Depends(get_triage_service)],
) -> TriageResult:
    return service.triage(ticket)

The two simulated failures now pass through different boundaries. The interactive documentation lets you verify both public responses.

  • Return to http://127.0.0.1:5000/docs in your browser.
  • Select the /triage operation.
  • Enable editing for the request body.
  • Replace the request body with this malformed-provider ticket:
{
            "subject": "Malformed model output",
            "message": "Please return a malformed provider response."
        }

What Does This Request Trigger?

The ticket makes FakeTriageProvider return invalid category values. It also returns an invalid urgency value.

TriageService catches the resulting validation failure. The HTTP handler converts it into a public response.

Before you submit this ticket, do you expect the previous 500 response to survive the new service boundary?

  • Submit the malformed-provider request.

You should see status code 502. The response detail should say AI provider returned invalid data.

That is the previous uncontrolled failure brought under control. Malformed provider output now has a stable public response.

  • Return to the request body editor.
  • Replace the request body with this provider-outage ticket:
{
            "subject": "Provider outage",
            "message": "Trigger provider-down for this support ticket."
        }

What Does This Request Trigger?

The provider-down text makes the fake provider raise ProviderUnavailableError. The outage handler converts that exception into a public response.

Before you submit this ticket, which status should distinguish provider unavailability from malformed data?

  • Submit the provider-outage request.

You should see status code 503. The response detail should say AI provider temporarily unavailable.

That is the reliability boundary working. Clients can now distinguish invalid provider data from temporary provider unavailability.

Still Seeing a 500 Response?

Confirm that triage_ticket() returns service.triage(ticket). Check that each exception decorator names its matching application error.

Ask for targeted help with tracing the uncontrolled response.

Your API now protects its HTTP contract with provider validation plus controlled error mapping. Next, you will lock those behaviors down with request-level tests.

Test the API Contract

Your API now turns provider failures into stable public responses. The interactive documentation proved those paths by hand.

Request-level tests use FastAPI's TestClient inside Pytest. They lock each public response into a check that survives refactoring.

In this step, get ready to:
  • Prove the valid billing response.
  • Prove client validation returns 422.
  • Prove provider failures preserve the public 502 and 503 responses.
Test successful requests and client validation

A request-level test calls your application through its public endpoint. TestClient runs the app in process.

Uvicorn does not serve these test requests. You can stop the development server before running the suite.

  • Return to the terminal running Uvicorn.
  • Stop the server by pressing Ctrl+C.
  • In the Explorer sidebar in Visual Studio Code, select the tests folder.
  • Create test_main.py inside tests with the New File button.

You should see test_main.py beneath the tests folder in the Explorer sidebar.

  • Add the client setup and billing contract test by copying this code into test_main.py:
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_triages_a_billing_ticket() -> None:
    response = client.post(
        "/triage",
        json={
            "subject": "Refund request",
            "message": "I was charged twice and need help with my invoice.",
        },
    )

    assert response.status_code == 200
    assert response.json() == {
        "category": "billing",
        "urgency": "low",
        "summary": (
            "Refund request: I was charged twice and need help with my invoice."
        ),
    }

What Does This Test Prove?

  • The shared client sends requests to the FastAPI application.
  • The billing ticket exercises the public /triage endpoint.
  • The assertions protect the 200 status code.
  • The final assertion protects every field in the returned JSON.
  • Save tests/test_main.py.

Before you run this check, predict whether every billing field matches the response contract.

  • Verify the billing contract by running this command in the project terminal:
python -m pytest

What Does This Check Do?

The command runs the tests in your active .venv. It checks the application through the request boundary.

You should see one passing test. Your billing response now has a repeatable contract.

The Billing Test Did Not Pass?

Confirm that the terminal is inside the ai-triage-api folder. Check that .venv is active.

Compare test_triages_a_billing_ticket() with the code above. A changed ticket message also changes the expected summary.

Ask for targeted help with diagnosing the billing contract test.

Client validation belongs to the same public contract. An undersized ticket should be rejected before TriageService receives it.

  • In tests/test_main.py, place the cursor below test_triages_a_billing_ticket().
  • Add the undersized-ticket test by copying this code:
def test_rejects_an_undersized_ticket() -> None:
    response = client.post(
        "/triage",
        json={"subject": "Hi", "message": "Tiny"},
    )

    assert response.status_code == 422

What Does This Test Prove?

  • The request uses a subject below the minimum length.
  • The message is also below the minimum length.
  • The status assertion protects FastAPI's 422 validation response.
  • Save tests/test_main.py.

Before you rerun the suite, predict whether the short request reaches the triage service.

  • Verify both request paths by running this command:
python -m pytest

What Does This Check Prove?

The suite now exercises a valid request and an invalid request. Both paths cross the same HTTP boundary.

You should see two passing tests. FastAPI is rejecting invalid input before the provider runs.

The Validation Test Did Not Pass?

Check that the request contains the exact Hi subject and Tiny message. Longer values can satisfy the model constraints.

Confirm that the assertion expects status code 422.

Ask for help with diagnosing the request-validation test.

Test controlled provider failures

The provider boundary protects clients from malformed model data and temporary outages. These tests pin the public response for each failure mode.

  • In tests/test_main.py, place the cursor below test_rejects_an_undersized_ticket().
  • Add the malformed-provider test by copying this code:
def test_maps_malformed_provider_output_to_502() -> None:
    response = client.post(
        "/triage",
        json={
            "subject": "Malformed model output",
            "message": "Please return a malformed provider response.",
        },
    )

    assert response.status_code == 502
    assert response.json() == {"detail": "AI provider returned invalid data"}

What Does This Test Prove?

  • The ticket activates the malformed branch in FakeTriageProvider.
  • The status assertion protects the public 502 mapping.
  • The JSON assertion prevents internal validation details from leaking to the client.
  • Save tests/test_main.py.

Before you run these tests, predict whether malformed provider data reaches the controlled handler.

  • Verify the malformed-provider contract by running this command:
python -m pytest

What Does This Check Prove?

The suite now checks the normal response and client validation. It also checks malformed provider output.

You should see three passing tests. The malformed response remains a controlled public failure.

The Malformed Test Did Not Pass?

Confirm that the ticket contains malformed. The fake provider uses that word to return invalid data.

Check that invalid_provider_response_handler() returns the same status and public detail asserted by the test.

Ask for help with tracing the malformed-provider path.

A provider outage follows a separate exception path. Its test protects the service-unavailable response that clients can handle safely.

  • In tests/test_main.py, place the cursor below test_maps_malformed_provider_output_to_502().
  • Add the provider-outage test by copying this code:
def test_maps_provider_outage_to_503() -> None:
    response = client.post(
        "/triage",
        json={
            "subject": "Provider outage",
            "message": "Trigger provider-down for this support ticket.",
        },
    )

    assert response.status_code == 503
    assert response.json() == {"detail": "AI provider temporarily unavailable"}

What Does This Test Prove?

  • The ticket activates the outage branch in FakeTriageProvider.
  • The status assertion protects the public 503 mapping.
  • The JSON assertion protects the stable outage detail returned to clients.
  • Save tests/test_main.py.

✔️ Awesome, I've got everything!

Your four request-level tests are in place.

ⓧ I'd like to double check the full code

from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_triages_a_billing_ticket() -> None:
    response = client.post(
        "/triage",
        json={
            "subject": "Refund request",
            "message": "I was charged twice and need help with my invoice.",
        },
    )

    assert response.status_code == 200
    assert response.json() == {
        "category": "billing",
        "urgency": "low",
        "summary": (
            "Refund request: I was charged twice and need help with my invoice."
        ),
    }


def test_rejects_an_undersized_ticket() -> None:
    response = client.post(
        "/triage",
        json={"subject": "Hi", "message": "Tiny"},
    )

    assert response.status_code == 422


def test_maps_malformed_provider_output_to_502() -> None:
    response = client.post(
        "/triage",
        json={
            "subject": "Malformed model output",
            "message": "Please return a malformed provider response.",
        },
    )

    assert response.status_code == 502
    assert response.json() == {"detail": "AI provider returned invalid data"}


def test_maps_provider_outage_to_503() -> None:
    response = client.post(
        "/triage",
        json={
            "subject": "Provider outage",
            "message": "Trigger provider-down for this support ticket.",
        },
    )

    assert response.status_code == 503
    assert response.json() == {"detail": "AI provider temporarily unavailable"}

How Should I Use This File?

Compare this reference with your saved tests/test_main.py file. Keep the request data and assertions exactly aligned.

Run the complete contract suite

The complete suite now covers one successful request and three guarded failure paths. A single run proves that all four public behaviors agree with the implementation.

Before you run the complete suite, predict whether all four public paths now agree with their tests.

  • Run the complete request-level suite with this command:
python -m pytest

What Does the Final Check Prove?

The command runs every test in tests/test_main.py. One result now checks the complete public API contract.

You should see 4 passed. Strong finish. Your API contract is now protected by repeatable request-level tests.

One of the Four Tests Failed?

  • Read the first failing assertion to find the earliest contract mismatch.
  • Compare the actual status code with the status asserted by that test.
  • Keep the expected contract assertions unchanged.
  • Correct the matching application layer in main.py or the app package.

Ask for help with diagnosing the first failing contract test.

Secret mission

Retry One Temporary Provider Failure

Make the fake provider fail once before recovering. Add a retry wrapper that absorbs the temporary interruption while preserving the API's controlled response for a permanent outage.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This local FastAPI project creates no ongoing charges.

Resources you used:

  • The local ai-triage-api folder with the application files.
  • The workspace-local .venv virtual environment with the pinned dependencies.
  • The local Uvicorn development server if it is still running.

Keep everything running

No action needed. Choose this if you want to extend or rerun the support-triage API.

  • Keep the ai-triage-api folder in its current location.
  • Keep .venv inside the folder so the pinned packages remain available.
  • Keep Uvicorn running only while you are actively testing the endpoint.

Your source files remain ready for future changes. The five tests still protect the endpoint contract.

Pause - I'll come back to this later

Stop the local server to free up memory. Keep the project folder so you can resume from the same tested state.

  • Return to the Visual Studio Code terminal from earlier.
  • Press Control+C if the Uvicorn server is still running.
  • Exit the active .venv environment by running this command:
deactivate

What does this command do?

The deactivate command leaves the workspace's virtual environment. It keeps every installed package inside .venv for your return.

The .venv prefix disappears from your terminal prompt. With the server stopped, Uvicorn no longer uses local memory.

Delete - I don't want to use this again

Remove the complete local workspace if you do not plan to revisit the project. Deleting the folder is permanent, but the command targets only ai-triage-api.

  • Return to the Visual Studio Code project terminal from earlier.
  • Press Control+C if the Uvicorn server is still running.
  • Remove the local workspace by running these commands:
deactivate
cd ..
rm -rf ai-triage-api
ls

What do these commands remove?

  • The deactivate command exits the active virtual environment before cleanup.
  • The cd command moves the terminal to the folder above ai-triage-api.
  • The rm command removes the named project folder with its source files, tests, and .venv environment.
  • The ls command lists the remaining folders so you can confirm the deletion.

You should no longer see ai-triage-api in the output. That closes the cleanup loop for every local resource this project created.

Nice Work!

Nice Work!

You did it! You built a tested support-triage API that protects its contract when provider data is valid, malformed, or unavailable.

You've learned how to:

  • Build a typed FastAPI endpoint that turns support tickets into structured categories, urgency levels, and summaries.
  • Use Pydantic validation to reject invalid client input and prevent malformed provider output from crossing the API boundary.
  • Separate provider, service, and HTTP layers with dependency injection. Map provider failures to controlled 502 and 503 responses. Prove the contract with request-level tests.
  • Secret Mission: Add a retry decorator that recovers from one temporary provider failure. Preserve the controlled response for a permanent outage. Confirm both behaviors with a fifth test.

Ready to quiz yourself?