Skip to content

INW012 thin-endpoint

What it does

Opt-in, and a warning by default. Reports an HTTP endpoint that does the work itself instead of handing it to a use case. One finding per endpoint, on its def line, lists every signal that tripped, with numbers. The counts include the helpers the endpoint calls in its own module, and the finding names each one:

Signal Default What counts
max-statements 10 Statements in the body, nested ones included. The docstring doesn't count, and neither does a guard (if <cond>: raise ..., or abort(404) in Flask) or an except handler that only raises: mapping errors to HTTP is the endpoint's job, whatever the exception.
max-branches 2 if and elif, case clauses and except handlers, guards left out.
max-nesting 2 How many compound statements (if, for, while, try, with, match) nest inside each other.
allow-loops false for and while in the body, except a loop that contains a yield: a streaming endpoint (server-sent events, a streamed download) builds its response that way, keep-alive while True: included. Comprehensions are allowed (allow-comprehensions = true), since [ItemOut.from_orm(i) for i in items] shapes a response.
deny-calls see below Calls whose qualified name matches a pattern, through imports and aliases: requests.*, httpx.*, aiohttp.*, urllib.request.*, smtplib.*, boto3.*, redis.*, sqlalchemy.*, psycopg*.*, asyncpg.*, celery.*, django.db.connection.*.
deny-receiver-types the SQLAlchemy and SQLModel sessions Method calls on a parameter whose annotation resolves to one of these types: db.execute, session.add, db.commit. The annotation may be plain, Annotated[T, ...], Optional[T], T \| None, or an alias defined in the same file.
deny-receiver-params none The same, by parameter name, for untyped code (db, session). Off by default because it guesses.
delegate-to unset Layer names or module prefixes and selectors. When set, the endpoint must call at least one function, or a method of a parameter, that comes from there.

An endpoint is a function or method that one of these marks:

  • FastAPI and Starlette: a path operation decorator (@router.get, post, put, patch, delete, head, options, trace or api_route) on a name the file binds to FastAPI(...) or APIRouter(...), or imports; or a registration away from the function: router.add_api_route(path, handler), Starlette's Route(path, handler) and FastAPI's APIRoute(path, handler), with the handler as the second argument or endpoint=.
  • Flask: @<x>.route, get, post, put, patch or delete on a name the file binds to Flask(...) or Blueprint(...), or imports; add_url_rule(rule, endpoint, view_func) with the handler as the third argument or view_func=; the methods named after HTTP verbs of a MethodView subclass, and dispatch_request of a View subclass.
  • Litestar: @get, @post, @put, @patch, @delete, @head, @route and HTTPRouteHandler imported from litestar (or litestar.handlers), on functions and on Controller methods alike.
  • Django and DRF: @api_view and @action; the methods named after HTTP verbs or ViewSet actions (list, create, retrieve, update, partial_update, destroy) of a subclass of a view class from django.views, rest_framework.views, rest_framework.generics or rest_framework.viewsets (View, APIView, GenericAPIView, ListCreateAPIView, ModelViewSet and the rest); a function a path(route, view) or re_path(...) call in the file names; and, only in a module modules names, a module-level function whose first parameter is request, since "a function that takes a request" is too broad anywhere else.
  • Your own: a configured decorator (decorators), and the HTTP verb and ViewSet action methods of a subclass of a configured base class (base-classes).

frameworks picks the recognisers; by default every framework the file mentions is on. A view class's base can come from the same file or another first-party module (class OrdersView(BaseView) where BaseView extends APIView). Websocket routes are left out: a receive loop is their job.

Files are checked whatever layer they are in, apart from test modules (check-tests = true brings them back, options tables); modules narrows the rule to some of them.

Why is this bad

Agents put the whole feature in the route handler: the query, the loop over rows, the call to a payment API, the email. The tests pass, and the service layer the project set up stays empty. Import rules can't see it: in the usual layouts the router and the service sit in the same layer, and a handler gets a database session through Depends(get_db) without importing SQLAlchemy at all. The style guides agree ("Don't do business logic in your API", HackSoft's Django Styleguide), and generic linters only measure size: Ruff's PLR0915 applies one limit of 50 statements to every function and doesn't know which one is an endpoint or what it calls.

Example

pyproject.toml
[tool.inwards]
layers = [
  { name = "application", modules = ["store.application"] },
  { name = "api", modules = ["store.api"] },
]

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

[tool.inwards.rules.thin-endpoint]
delegate-to = ["application"]
store/application/orders.py
from dataclasses import dataclass


class OutOfStock(Exception): ...


@dataclass
class Order:
    id: int
    total: int


async def place_order(customer_id: int, lines: list[tuple[int, int]]) -> Order:
    raise NotImplementedError
store/api/db.py
class Customer: ...


class Product: ...


class OrderRow: ...


async def get_db(): ...
store/api/orders.py
from typing import Annotated

import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from store.api.db import Customer, OrderRow, Product, get_db

router = APIRouter()


@router.post("/orders", status_code=201)
async def create_order(body: dict, db: Annotated[AsyncSession, Depends(get_db)]) -> dict:
    customer = await db.scalar(select(Customer).where(Customer.id == body["customer"]))
    if customer is None:
        raise HTTPException(404, "customer not found")
    total = 0
    for product_id, qty in body["lines"]:
        product = await db.get(Product, product_id)
        if product.stock < qty:
            raise HTTPException(409, "out of stock")
        product.stock -= qty
        total += product.price * qty
    order = OrderRow(customer_id=customer.id, total=total)
    db.add(order)
    await db.commit()
    httpx.post("https://hooks.example.com/orders", json={"order": order.id})
    return {"id": order.id, "total": total}
inwards check store
store/api/orders.py:14:1: INW012 (warning) `create_order` is an HTTP endpoint with 11 statements (max 10), 1 loop over data, direct calls (`db.scalar`, `select`, `db.get`, `db.add`, `db.commit`, `httpx.post`) and no call into the `application` layer (`store.application`). Endpoints parse the request, call one use case, and shape the response.
  fix: Move the work out of `create_order` into the `application` layer (`store.application`), and keep the endpoint to HTTP.
    1. Move the logic (lines 15 to 29) into a function in the `application` layer (`store.application`) that takes plain arguments and returns a domain object or a result the endpoint maps to the response.
    2. Move `db.scalar`, `db.get`, `db.add` and `db.commit` behind a repository in an outer layer, and give the use case a Protocol it can call.
    3. Move `select` and `httpx.post` into an adapter (a repository, an API client or a background task) that the use case reaches through a Protocol.
    4. In the endpoint keep only: read the request, call the use case, map its errors to `HTTPException`, and return the response model.
    5. Don't move the code into a helper function in the same module: INW012 counts a helper's body as the endpoint's, and the work would still live in the HTTP layer.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW012/

The two guards that raise HTTPException don't count, so the body has 11 statements, not 13. A warning doesn't fail the check: the command still exits 0.

Moving the body into a helper in the same module doesn't hide it. The helper's statements, loops and calls count as the endpoint's, its db parameter takes the session type of the argument passed to it, and the finding names the helper:

store/api/orders.py
from typing import Annotated

import httpx
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from store.api.db import OrderRow, get_db

router = APIRouter()


@router.post("/orders", status_code=201)
async def create_order(body: dict, db: Annotated[AsyncSession, Depends(get_db)]) -> dict:
    return await _create_order(body, db)


async def _create_order(body, db):
    total = 0
    for _product_id, qty in body["lines"]:
        total += qty
    order = OrderRow(customer_id=body["customer"], total=total)
    db.add(order)
    await db.commit()
    httpx.post("https://hooks.example.com/orders", json={"order": order.id})
    return {"id": order.id, "total": total}
inwards check store
store/api/orders.py:13:1: INW012 (warning) `create_order` is an HTTP endpoint with 1 loop over data, direct calls (`db.add`, `db.commit`, `httpx.post`) and no call into the `application` layer (`store.application`), counting the same-module helper `_create_order` (line 17). Endpoints parse the request, call one use case, and shape the response.
  fix: Move the work out of `create_order` into the `application` layer (`store.application`), and keep the endpoint to HTTP.
    1. Move the logic (line 14, and `_create_order` at lines 18 to 25) into a function in the `application` layer (`store.application`) that takes plain arguments and returns a domain object or a result the endpoint maps to the response.

Fixed: the endpoint calls the use case and maps its error to HTTP.

store/api/orders.py
from fastapi import APIRouter, HTTPException

from store.application.orders import OutOfStock, place_order

router = APIRouter()


@router.post("/orders", status_code=201)
async def create_order(body: dict) -> dict:
    try:
        order = await place_order(body["customer"], body["lines"])
    except OutOfStock as e:
        raise HTTPException(409, str(e)) from e
    return {"id": order.id, "total": order.total}
inwards check store

With one package per domain, as in the fastapi preset, the target layer's modules are a selector such as blog.*.service. The finding then names the module the selector stands for in the endpoint's own package, so the agent doesn't have to work it out from the wildcard:

pyproject.toml
[tool.inwards]
layers = [
  { name = "service", modules = ["blog.*.service"] },
  { name = "router", modules = ["blog.*.router"] },
]

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

[tool.inwards.rules.thin-endpoint]
delegate-to = ["service"]
blog/posts/service.py
def count_words(text: str) -> dict[str, int]:
    raise NotImplementedError
blog/posts/router.py
from fastapi import APIRouter

router = APIRouter()


@router.get("/posts/{post_id}/words")
async def post_words(post_id: int, text: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts
inwards check blog
blog/posts/router.py:7:1: INW012 (warning) `post_words` is an HTTP endpoint with 1 loop over data and no call into the `service` layer (`blog.posts.service`). Endpoints parse the request, call one use case, and shape the response.
  fix: Move the work out of `post_words` into the `service` layer (`blog.posts.service`), and keep the endpoint to HTTP.

A delegate-to entry that is a selector itself, blog.*.service, reads "blog.posts.service (a module matching blog.*.service)". When the endpoint's module doesn't fit the selector (an endpoint outside blog, or a selector that ends in a wildcard), the finding names the selector as configured.

Class-based views get the same treatment. A DRF ViewSet action in the same project is named after its class:

blog/posts/router.py
import requests
from rest_framework import viewsets
from rest_framework.response import Response


class PostViewSet(viewsets.ViewSet):
    def list(self, request):
        posts = requests.get("https://cms.example.com/posts").json()
        return Response([post for post in posts if post["published"]])
inwards check blog
blog/posts/router.py:7:5: INW012 (warning) `PostViewSet.list` is an HTTP endpoint with a direct call to `requests.get` and no call into the `service` layer (`blog.posts.service`). Endpoints parse the request, call one use case, and shape the response.

How it works

A text pre-filter runs first: a file that mentions none of the recognised frameworks (fastapi, FastAPI, APIRouter, starlette or add_api_route for FastAPI, flask, litestar, django or rest_framework) and not the last part of a configured decorator or base class is never parsed. With frameworks set, only the listed frameworks' words count, and the other recognisers don't run. A file that passes gets one parse of its own, and another module is read and parsed only when a registration in the file names a handler there.

  • Endpoints come from decorators, at any depth. A FastAPI or Flask route decorator counts on a name the file binds to an app or router, or on an imported name, as in the FastAPI model the FAPI rules read. A FastAPI router built by a first-party router class or factory counts too, resolved the same way as for the FAPI rules (below); Litestar's and DRF's decorators count by their qualified name. A method's finding names it Class.method. A decorators entry matches the qualified name of what the decorator calls or names, with * and ? as wildcards (* also crosses dots, as in fnmatch).
  • View classes count by their bases: a framework view class, a base-classes entry, or a class whose own bases lead to one, in the same file or, through the project index, in another first-party module (a cycle of bases ends the search). Only methods defined in the class's own body count; inherited handlers and hooks such as get_queryset or perform_create don't.
  • Registered handlers come from add_api_route and add_url_rule on an app or router (bound in the file or imported), and from Route(...), APIRoute(...), path(...) and re_path(...) calls. A handler defined at module level in the same file is reported on its def line, like a decorated one. A handler from another first-party module is found through the project index, following re-exports such as from .views import create_order in an __init__.py, and reported on the registration, since that is the file being checked: the message names the handler's file and line. A handler that is already an endpoint in its own module (a route decorator marks it there) is left to that module's check, so it isn't reported twice.
  • Metrics come from one walk over the body. A nested def, class or lambda counts as one statement, and what is inside it is its own business. A markup tree counts as one statement and adds no nesting (#360): a with whose context expressions are calls bound to no name (with tag.div(classes="card"):), holding only such blocks, bare calls such as text(...), pass and comments. That is how dominate and tagflow build HTML, and the tree is the response's shape, not the feature.
  • Health checks aren't reported (#360): an endpoint whose route path or function name has health, healthz, healthcheck, livez, readyz, liveness or readiness as a word of its own (split on /, -, _ and .). Probing the database and the cache is what such an endpoint is for; Kubernetes calls /livez and /readyz (Kubernetes: API endpoints for health).
  • Helpers are module-level functions of the endpoint's own module that it calls by name (_create_order_impl(body)). Their bodies count as the endpoint's: statements, branches, loops and comprehensions add up, nesting is the deepest of any one body, and their calls are sorted like the endpoint's own, delegate-to included, so a helper that calls the use case counts as the endpoint calling it. A helper's helpers are followed too, up to three calls deep from the endpoint, and each function counts once per endpoint, which also stops recursion. A helper's parameter reads its own annotation, or, without one, the type of the endpoint's parameter passed to it. One finding per endpoint names every helper it counted, with its line; helpers get no finding of their own, and a helper two endpoints call counts in both. Not followed: a Depends(...) target (FastAPI calls it, the endpoint doesn't), another endpoint, a nested function, a function imported from another module, and a method.
  • Calls are qualified through the file's imports, so from httpx import post as send makes send(...) a call to httpx.post. A call on a parameter reads the parameter's annotation and its Depends(...) target: place_order(...) with place_order: Annotated[PlaceOrder, Depends(get_place_order)] reaches both PlaceOrder and get_place_order.
  • delegate-to asks which layer owns each name a call reaches (a layer name), or whether a prefix or selector matches it.
  • Fix steps name the delegate-to target (for a selector, the module it stands for in the endpoint's package: the part up to its last wildcard is matched against the endpoint's module, and the literal rest is appended), the session calls to move behind a repository (in the layer the session's type or dependency comes from, when a layer other than the endpoint's own owns it), and the denied calls to move into an adapter.

How to fix

  1. Move the loop and the business rules into a function in the delegate-to layer (a use case or a service) that takes plain arguments and returns a domain object.
  2. Move the session calls behind a repository, and give the use case a Protocol it can call.
  3. Move outgoing calls (HTTP, mail, queues) into an adapter or a background task that the use case triggers.
  4. In the endpoint, keep only what HTTP needs: read the request, call the use case, map its errors to HTTP (HTTPException, abort(), Http404 or a DRF exception), and return the response.

Don't split the endpoint into helpers in the same module to get under the limits: INW012 counts them as part of the endpoint, and the work would still live in the HTTP layer.

Fix safety

No automatic fix (autofix: false). Where the logic goes, and what the use case's interface looks like, is a design decision.

Configuration

Severity warning, so a finding doesn't fail inwards check or block the Stop gate; severity = { INW012 = "error" } makes it blocking. Opt-in: turn it on with extend-select. The options live in [tool.inwards.rules.thin-endpoint] (options tables), and an agent can't edit them (the config guard covers the whole table). The fastapi preset of inwards init turns INW012 on as a warning with delegate-to = ["domain.service"], the service module of every domain package:

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

[tool.inwards.rules.thin-endpoint]
modules = ["shop.*.api"]                    # optional: only these modules
max-statements = 10                         # false turns a threshold off
max-branches = 2
max-nesting = 2
allow-loops = false
allow-comprehensions = true
deny-calls = ["requests.*", "httpx.*"]      # replaces the default list
extend-deny-calls = ["shop.integrations.stripe.*"]  # adds to it
deny-receiver-types = ["sqlalchemy.orm.Session", "sqlalchemy.ext.asyncio.AsyncSession"]
deny-receiver-params = ["db"]               # untyped code
delegate-to = ["application"]               # layer names, prefixes or selectors
decorators = ["shop.http.endpoint"]         # in-house endpoint decorators
frameworks = ["fastapi", "django"]          # default: whatever the file mentions
base-classes = ["shop.http.Resource"]       # in-house view base classes
  • max-statements, max-branches, max-nesting: an integer from 0 to 1000, or false to turn the check off.
  • deny-calls, extend-deny-calls, deny-receiver-types, decorators, base-classes: qualified names with * and ? as wildcards. deny-calls = [] turns the call check off; deny-receiver-types replaces its default list (sqlalchemy.orm.Session, sqlalchemy.orm.session.Session, sqlalchemy.ext.asyncio.AsyncSession, sqlalchemy.ext.asyncio.session.AsyncSession, sqlmodel.Session, sqlmodel.ext.asyncio.session.AsyncSession).
  • delegate-to: a layer name from layers, or a module prefix or selector in the grammar of layers[].modules. An entry that is neither is a config error.
  • frameworks: distinct entries from fastapi (Starlette included), flask, litestar and django (DRF included). Unset, a framework's recogniser runs in every file that mentions it.
  • Django's ORM: a manager call such as Order.objects.filter(...) isn't denied by default, since it is how Django code reads the database and a default would flag every view. To treat it as database work in a view, add extend-deny-calls = ["*.objects.*"]. Django's raw connection.cursor() is in the default list.

Every finding can be suppressed inline with # inwards: ignore[INW012] reason="..." on the line it is reported on: the def line, or for a handler from another module, the line of the registration.

A template can turn INW012 on for one of its roles, with its options: router = { thin-endpoint = { max-statements = 8 } } sets modules to the role's modules.

Known limitations

  • Business logic or presentation. An endpoint that builds a CSV, paginates or sets cache headers is long for good reasons. The finding lists numbers, not a verdict; raise a threshold or narrow modules.
  • Simple CRUD apps. Some teams choose db.get(...) in the handler on purpose. That is why the rule is opt-in.
  • Indirection. s = db; s.execute(...), a session passed through **kwargs, a type alias imported from another module and string annotations all hide calls. Helpers are followed only in the endpoint's own module and only three calls deep; a helper imported from another module, a method on a class, and a function stored in a variable or a dict aren't followed.
  • Handlers registered from another module are reported where they are registered, not where they are defined. A per-edit check of the handler's own module (the PostToolUse hook after an edit there) doesn't see the registration; the whole check (inwards check, the Stop gate) and an edit of the registering file do.
  • Framework detection is by text. A file is read for a framework only when it mentions it, so a blueprint imported from a first-party module (from shop.web import bp) in a file that never says flask isn't recognised, and a view base from another module is followed only in a file that mentions Django, DRF or Flask. List such bases in base-classes.
  • Not linked: include(...) in urls.py, as_view() in a registration (the class's own methods count instead), Starlette's Mount and add_route, Flask's register_blueprint, Litestar's Router(route_handlers=[...]) for a function with no decorator, and a handler passed through a variable.
  • Inherited handlers. A generic view that only overrides hooks (get_queryset, perform_create, get_serializer_class) isn't checked: the hooks aren't request handlers, and the handlers live in the framework.
  • Guards. Any raise, and Flask's abort(...), counts as mapping an error to HTTP, the project's own exceptions included: an app-wide exception handler (FastAPI's exception_handler, DRF's EXCEPTION_HANDLER) maps them, and one file can't show which. So a business rule written as a guard (if order.total > limit: raise LimitExceeded()) isn't counted either; the work around it still is. An if (with no elif or else) or an except whose block makes one bare call and then returns counts as a guard too (#360): an HTMX form handler answers an error with await add_toast(request, "Not found.", "error") and return None where a JSON API raises. A business step written that way (if order.paid: await send_receipt(order); return order) isn't counted either; two calls before the return are, and so is a return with no call before it: Saleor's thumbnail views return HttpResponseNotFound(...) from the branches that hold their logic, and counting those as guards lost a true positive.
  • Custom decorators that wrap a handler and do the work themselves aren't read.
  • SQLAlchemy loader options (joinedload, selectinload, contains_eager, load_only and the rest of sqlalchemy.orm's loader and column options) are never a denied call, even with deny-calls set: they only tell a statement what to load (SQLAlchemy: Relationship Loading Techniques), and an endpoint passes them to a repository to shape its response.
  • False positives: a method named execute on a service typed as Session by mistake. A comprehension that does I/O counts as allowed, though the calls inside it are still checked. A with block that holds only bare calls counts as markup even when it isn't (with transaction.atomic(): order.save()): its calls are still checked, but its statements and nesting aren't counted. On real code (below): a webhook that verifies its signature in the endpoint (verify it in a dependency instead, Depends(verify_signature), which INW012 doesn't count because FastAPI calls it), a server-sent event stream whose generator is a same-module helper, and back-office form handlers that update a repository themselves.
  • Which routers count (#358). A route counts on a name bound to FastAPI's FastAPI(...) or APIRouter(...), and, when the file calls a route decorator, include_router or another router method on the name, also to a first-party router class or factory, which Inwards resolves through the project's imports: a subclass of FastAPI's class; a module-level NAME = factory(...) whose first-party class factory says or shows that it returns a router class (-> type[APIRouter], or a subclass it defines and returns, as Polar's get_api_router_class does); any module-level APIRouter = <call> or FastAPI = <call>; a first-party function that returns a router, one hop deep; and a name returned by a function annotated -> APIRouter (fastapi-clean-example's make_*_router() factories). A callee merely named APIRouter doesn't count: OpenStack projects name their WSGI routers that. A factory's arguments aren't the router's keywords, so a factory-built router's prefix, tags and responses= are unknown, and its routes count as shown. A file that spells none of the pre-filter's words (fastapi, APIRouter, include_router...) isn't read.

Measured precision

Measured on the real-repo corpus on 2026-10-11 (#182), with the default thresholds. For #182, Polar's endpoints needed decorators = ["polar.*.router.get", "polar.*.router.post", ...], since its router class is built at run time; since #358 they need nothing.

Repo Endpoints Findings before → after #182 True positives in the sample
polar (decorators set) 450 108 → 73 17 of 25 (68 %); before the fixes 8 of 22 (36 %)
full-stack-fastapi-template 23 1 → 1 1 of 1
saleor (re_path registrations) n/a 2 → 2 2 of 2
polar (no decorators: the worker's Starlette Route) 1 1 → 1 0 of 1 (a health check)
polar, run-time routers recognised (#358) 526 1 → 135 10 of 27 (37 %), every fifth
fastapi-clean-example, routes in router factories (#358) 15 0 → 0 n/a
polar, every router recognised (#358), markup, early answers and health checks (#360) 526 135 → 109 10 of 22 (45 %); before 10 of 27 (37 %)

The two fixes: a guard or handler that raises the project's own exception isn't counted (8 of the 14 false positives in the first sample, at the cost of 1 true positive, a business rule written as two guards), and loader options aren't denied calls (3 false positives across both samples). The thresholds (10 statements, 2 branches, 2 levels) held up: of Polar's 73 findings only 3 trip a single signal by 2 statements, 1 branch or 1 level or less, so raising them would barely change the count. The remaining false positives are presentation (46 of the 73 findings are in Polar's server-rendered back office); narrow modules for code like that. With every Polar router recognised (#358), 107 of the 135 findings are in the back office, and 17 of the 27 sampled are false: 15 server-rendered pages and HTMX form handlers there, a server-sent event stream and a webhook (#360).

#360 measured each change on Polar with every router recognised, against the same every-fifth sample of 27 findings (10 true): markup trees took the findings from 135 to 121, early answers alone to 129, health checks alone to 133, and all three to 109. 5 of the sample's 17 false positives went and no true positive did. Of the 26 findings that went, 2 are the health checks, 23 are back-office pages, dialogs and form handlers, and 1 is a redirect endpoint that calls one service. full-stack-fastapi-template, fastapi-clean-example and python-ddd didn't change, and Saleor kept its 2 findings. The 12 false positives left are 9 back-office form handlers and dialogs that still run 11 to 16 statements around one service or repository call, a back-office page that renders rows in loops, a server-sent event stream and a webhook.

References

  • #182: the design, and what is left for later versions.
  • #271: same-module helpers and registered routes.
  • HackSoft Django Styleguide: services and selectors hold the business logic, not the API.
  • fastapi-best-practices: routers keep to HTTP, services do the work.
  • Ruff's PLR0915, PLR0912 and C901 measure every function with one limit.