Przejdź do treści

FAPI002 undocumented-error-response

Co robi

Zgłasza operację ścieżki FastAPI, która może zwrócić kod błędu (domyślnie 4xx) niezadeklarowany w jej wpisie OpenAPI. Jedna diagnostyka na endpoint, na jego dekoratorze, wymienia kody i skąd każdy pochodzi, żeby dało się to sprawdzić. Kod liczy się jako zwracany w kolejnych pierścieniach:

Pierścień Źródło
0 raise HTTPException(404) albo HTTPException(status_code=404) w endpoincie oraz return JSONResponse(..., status_code=404) (dowolna klasa odpowiedzi FastAPI albo Starlette)
1 To samo w funkcji z tego samego pliku, którą endpoint wywołuje po nazwie, albo w zależności podanej jako Depends(fn) lub Annotated[T, Depends(fn)]
2 To samo we własnej funkcji albo zależności zaimportowanej z innego modułu, czytanej leniwie. dependencies=[Depends(...)] na dekoratorze, na APIRouter(...), na include_router(...) albo na FastAPI(...) liczy się dla każdej trasy pod nim
3 Rzucony własny wyjątek, na który handler wyjątków aplikacji odpowiada literalnym kodem statusu, szukany przez klasy bazowe wyjątku tak jak w Starlette; oraz podklasa HTTPException, której __init__ ustala kod

Pierścienie 1 i 2 idą najwyżej max-depth wywołań w głąb (domyślnie 2). Kod to literał całkowity, stała status.HTTP_* z FastAPI albo Starlette, element HTTPStatus albo własna stała modułu, która trzyma jedno z nich.

Kod liczy się jako zadeklarowany, gdy ma go responses= (klucz liczbowy albo tekstowy, albo "4XX", "5XX" lub "default") na dekoratorze, na APIRouter(...) trasy, na include_router(...), które do niej prowadzi, albo na FastAPI(...); liczą się też **NAZWA słownika z poziomu modułu i openapi_extra={"responses": {...}}. FastAPI sam dokumentuje 422 dla operacji z parametrami, więc FAPI002 też tak robi, chyba że explicit-422 = "report".

Dlaczego to źle

FastAPI buduje schemat z tego, co kod deklaruje, i nie widzi raise (fastapi#9124: zadeklaruj to w responses=). Agent dodaje 404 w funkcji pomocniczej albo błąd domenowy, który handler aplikacji zamienia na 409, i nie rusza dekoratora. Wygenerowany klient nie ma wtedy gałęzi dla tego błędu, a frontend dowiaduje się o nim na produkcji.

Przykład

Z trzema warstwami z INW001 włącz FAPI002:

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]

[tool.inwards.rules]
extend-select = ["FAPI002"]

Serwis rzuca błąd domenowy, a aplikacja zamienia go na 409:

shop/infrastructure/db.py
import shop.domain.order


class OrderOut: ...


class OutOfStock(Exception): ...


def reserve(order: OrderOut) -> None:
    raise OutOfStock()
shop/infrastructure/app.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from shop.infrastructure import orders
from shop.infrastructure.db import OutOfStock

app = FastAPI()
app.include_router(orders.router)


@app.exception_handler(OutOfStock)
async def out_of_stock(request: Request, exc: OutOfStock) -> JSONResponse:
    return JSONResponse(status_code=409, content={"detail": "Out of stock"})

Endpoint wczytuje zamówienie przez funkcję pomocniczą, która rzuca 404, a potem je rezerwuje:

shop/infrastructure/orders.py
from fastapi import APIRouter, HTTPException

from shop.infrastructure.db import OrderOut, reserve

router = APIRouter(prefix="/orders", tags=["orders"])


def load_or_404(order_id: int) -> OrderOut:
    raise HTTPException(status_code=404, detail="No such order")


@router.post("/{order_id}/confirm", status_code=200, summary="Confirm an order")
async def confirm_order(order_id: int) -> OrderOut:
    order = load_or_404(order_id)
    reserve(order)
    return order
inwards check
shop/infrastructure/orders.py:12:1: FAPI002 `confirm_order` can return 404 and 409, which its OpenAPI entry doesn't declare.
  fix: Declare 404 and 409 in `responses=` on `@router.post("/{order_id}/confirm")`.
    1. `confirm_order` can return 404 (from `load_or_404` at shop/infrastructure/orders.py:9) and 409 (from `OutOfStock` raised at shop/infrastructure/db.py:11, handled in shop/infrastructure/app.py:11), but its OpenAPI entry declares none of them.
    2. Add `responses={404: {"description": "..."}, 409: {"description": "..."}}` to the decorator (with a "model" where the error has a body), or to `APIRouter(...)` if every route shares them.
    3. Don't remove the `raise`, and don't catch the error only to silence this finding: clients generated from the schema need to know the error exists.
  docs: https://sircypkowskyy.github.io/inwards/rules/FAPI002/

Poprawiony: dekorator deklaruje oba błędy.

shop/infrastructure/orders.py
from fastapi import APIRouter, HTTPException

from shop.infrastructure.db import OrderOut, reserve

router = APIRouter(prefix="/orders", tags=["orders"])


def load_or_404(order_id: int) -> OrderOut:
    raise HTTPException(status_code=404, detail="No such order")


@router.post(
    "/{order_id}/confirm",
    status_code=200,
    summary="Confirm an order",
    responses={
        404: {"description": "No such order"},
        409: {"description": "A product is out of stock"},
    },
)
async def confirm_order(order_id: int) -> OrderOut:
    order = load_or_404(order_id)
    reserve(order)
    return order
inwards check

Jak naprawić

  1. Sprawdź każdy kod u źródła: diagnostyka podaje plik i linię każdego raise albo handlera.
  2. Zadeklaruj kody w responses=, z description i, gdy błąd ma treść, z model. Umieść je na APIRouter(...) albo include_router(...), gdy dzielą je wszystkie trasy pod nim.
  3. Nie usuwaj raise i nie łap błędu tylko po to, żeby diagnostyka zniknęła.

Bezpieczeństwo poprawki

Bez automatycznej poprawki (autofix: false). Opis i model błędu są częścią API.

Konfiguracja

Opt-in: reguła zgłasza tylko wtedy, gdy extend-select albo select wymienia FAPI002. Jej opcje z wartościami domyślnymi:

pyproject.toml
[tool.inwards.rules]
extend-select = ["FAPI002"]

[tool.inwards.rules.undocumented-error-response]
codes = "4xx"                         # or "4xx-5xx"
max-depth = 2                         # calls followed into helpers and dependencies, 0 to 8
report-direct-raises = true           # false leaves raises in the endpoint itself to Ruff FAST004
handled-counts-as-documented = false  # true: codes from app exception handlers count as declared
explicit-422 = "ignore"               # "report": a raised 422 must be declared too

Nieznany klucz albo zła wartość to błąd konfiguracji, który podaje klucz. Jak każda reguła, przyjmuje też modules, a jak każda reguła treści także check-tests, które sprawia, że zgłasza też w modułach testowych (tabele opcji). Diagnostykę można wyciszyć w linii dekoratora przez # inwards: ignore[FAPI002] reason="...".

FAST004 Ruffa (w recenzji) obejmuje pierścień 0 z responses= w tym samym pliku. Gdy trafi do wydania, report-direct-raises = false sprawi, że obie reguły nie zgłoszą tego samego raise.

Znane ograniczenia

Nieznane znaczy ciche: FAPI002 woli przeoczyć diagnostykę niż zgłosić błędną.

  • Kod statusu, którego nie umie odczytać (HTTPException(code)), responses=, które nie jest literałem (responses=build()), dekorator z **kwargs albo dołączenie, którego nie umie rozwiązać (include_router(getattr(m, "router")), fabryka, której zwracanego routera nie umie odczytać, include_router(r, **opts)), sprawia, że ten kod albo endpoint jest nieznany i nic dla niego nie jest zgłaszane. Jedno nierozwiązane dołączenie gdziekolwiek sprawia, że nieznana jest każda trasa na routerze. Dołączenia są czytane z grafu aplikacji i routerów, którego używa FAPI003 (graph.ts), więc liczy się pętla po literalnej liście routerów, a także parametry funkcji i wywołanie fabryki routera, o jeden krok w głąb (FAPI003).
  • Metody wstrzykniętych obiektów (svc.place()) nie są śledzone, podobnie jak kod zewnętrzny, wywołania przez getattr i wszystko, co aplikacja robi w czasie działania (trasy dodawane w pętlach, nadpisane app.openapi(), middleware).
  • Nie ma analizy przepływu: raise liczy się, jeśli jest w funkcji, nawet w gałęzi, do której ten endpoint nigdy nie trafia. except X wokół wywołania odejmuje tylko własne wyjątki, które są X albo po nim dziedziczą.
  • Handlery wyjątków liczą się tylko w aplikacjach, które obsługują trasę: w tej, na której trasa jest zadeklarowana, albo w każdej aplikacji, która dołącza jej router, bezpośrednio albo przez inne routery. Handler w innej aplikacji (na przykład w aplikacji health-check workera) się nie liczy, podobnie jak handler w aplikacji, która montuje aplikację trasy, bo zamontowana aplikacja sama obsługuje swoje wyjątki.
  • Handlery rejestrowane na parametrze funkcji (add_exception_handlers(app) Polara) nie są czytane, podobnie jak kod, który handler bierze z wyjątku (status_code=exc.status_code); oba przypadki to #400.
  • Handlery są czytane z @app.exception_handler(X), app.add_exception_handler(X, h) (także w pętli for po literalnej liście albo krotce wyjątków) i z exception_handlers= w FastAPI(...). Ta tabela może być słownikiem, nazwą związaną ze słownikiem w tym samym pliku albo wpisem "exception_handlers" w słowniku rozpakowanym w FastAPI(**kwargs), także w słowniku, który wywołanie w tym samym pliku przekazuje do fabryki aplikacji. **kwargs, którego słownik ustawia inne klucze, nadal ukrywa pozostałe argumenty aplikacji i jej trasy pozostają nieznane.
  • Rzucony własny wyjątek nic nie wnosi w aplikacji z rejestracją, której Inwards nie umie odczytać: pętlą po słowniku wyjątków albo rozpakowanym słownikiem, który pochodzi z innego pliku, jest zmieniany w miejscu (kwargs.update(...)) albo należy do fabryki, której żadne wywołanie w pliku nie osiąga. Tak samo jest z klasą dziedziczącą po wyjątku zewnętrznym, którego handlera Inwards może nie widzieć. Handler dla klasy wbudowanej albo zewnętrznej, na przykład catch-all dla Exception, jest czytany, ale nie mapuje żadnego kodu: taki handler często wybiera kod w środku, według typu wyjątku.
  • Funkcja-generator wywołana po nazwie (treść StreamingResponse) nie jest czytana, bo wywołanie jej nie uruchamia.
  • Gdy do jednego routera prowadzi kilka dołączeń, liczy się kod zadeklarowany w którymkolwiek z nich.
  • Żeby ustalić, co jest nad routerem, FAPI002 czyta raz na sprawdzenie każdy plik projektu z FastAPI. W hooku po edycji robi to tylko dla trasy z kodem, którego nie deklarują jej dekorator ani router.
  • Które routery się liczą (#358). Trasa liczy się na nazwie powiązanej z FastAPI(...) albo APIRouter(...) z FastAPI, a gdy plik wywołuje na tej nazwie dekorator trasy, include_router albo inną metodę routera, także z własną klasą albo fabryką routera, którą Inwards rozwiązuje przez importy projektu: podklasą klasy FastAPI; NAZWA = fabryka(...) na poziomie modułu, gdy własna fabryka klas mówi albo pokazuje, że zwraca klasę routera (-> type[APIRouter] albo podklasa, którą definiuje i zwraca, jak get_api_router_class w Polarze); dowolnym APIRouter = <wywołanie> albo FastAPI = <wywołanie> na poziomie modułu; własną funkcją, która zwraca router, jeden krok w głąb; oraz nazwą zwracaną przez funkcję z adnotacją -> APIRouter (fabryki make_*_router() w fastapi-clean-example). Samo wywołanie czegoś, co nazywa się APIRouter, się nie liczy: projekty OpenStack tak nazywają swoje routery WSGI. Argumenty fabryki nie są słowami kluczowymi routera, więc prefiks, tagi i responses= routera z fabryki są nieznane, a jego trasy liczą się jako widoczne w schemacie. Plik, który nie zawiera żadnego słowa z filtra tekstowego (fastapi, APIRouter, include_router...), nie jest czytany.
  • Klasy tras (#392). FastAPI buduje każdą trasę klasą route_class jej routera i buduje ją ponownie, z połączonymi tagami, przy każdym include_router (routing.py, add_api_route i include_router). Klasa tras pochodzi z route_class= albo z własnej klasy routera, która ją ustawia (kwargs["route_class"] = route_class w fabryce klas, jak get_api_router_class Polara). Gdy jeden __init__ wśród jej własnych klas ustawia self.include_in_schema na podstawie tagów, Inwards wykonuje ten kod dla każdej trasy: łańcuch if/elif/else po <tag> in self.tags (albo lokalnej nazwie z nim związanej) i self.include_in_schema, przypisujący True albo False. Tagi to napisy albo elementy własnych enumów przechowujące napis (APITag.private Polara, StrEnum), z dekoratora, routera i każdego include_router nad nim. Trasa jest pomijana, gdy klasa ją wyklucza, a także gdy odpowiedź jest nieznana: wartość inna niż literał (settings.is_development() Polara dla tras prywatnych), warunek albo instrukcja, której Inwards nie umie odczytać, tag, którego nie umie odczytać, albo klasa tras, która zapisuje self.tags. Klasa tras, która nie dotyka schematu, niczego nie zmienia.
  • Zmierzona na korpusie (#182, rozdział 6): 18 zgłoszeń przed regułą o openapi_url, 17 po niej; 8 obejrzanych, wszystkie trafne (każdy podany kod rzuca endpoint albo zależność). Usunięte było na back office Polara, aplikacji z openapi_url=None. Od #358 model widzi 526 operacji ścieżki Polara poza testami i 15 z fastapi-clean-example, Odkąd #391 śledzi parametr funkcji, przez który Polar dołącza swój router API, i wywołania fabryk, przez które oba projekty dołączają routery, każde dołączenie w obu jest znane. Polar ma wtedy 1 zgłoszenie, trafne (trasa OAuth2 authorize, która rzuca 401 i niczego nie deklaruje); większość pozostałych jego tras rzuca podklasy PolarError, których handlera FAPI002 jeszcze nie umie odczytać (#400), a trasy webhooków i integracji mają include_in_schema=False. fastapi-clean-example nadal nie ma żadnego: jego routery buduje fabryka wokół zewnętrznego ErrorAwareRouter, którego argumenty kluczowe, a więc i deklarowane odpowiedzi, są nieznane.

Źródła