diff --git a/changelog.d/326.added.md b/changelog.d/326.added.md new file mode 100644 index 0000000..3e63b15 --- /dev/null +++ b/changelog.d/326.added.md @@ -0,0 +1,11 @@ +**`@cache` warns once when a credential makes a route bypass the backend.** +The first request that bypasses the shared backend on a route because it +carried an `Authorization` header, a session token or non-empty +`request.session` data is logged at `WARNING` on the `fastapi_cachex.cache` +logger, once per route and credential kind, naming the route template and the +credential (never its value). The message points to `public=True` for a +response that is the same for every user (which also sends `Cache-Control: +public` downstream) and to `cache_authorized=True` with a per-user +`key_builder` for a per-user one. Each bypass is still logged at `DEBUG`. +`docs/HTTP_CACHING.md` gains a "Requests with credentials" section on choosing +between the two. diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 60687e8..2453040 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -137,7 +137,9 @@ A response that belongs to one caller is never stored either (#296): included. A token that resolves to no session (forged, expired) does not count, so it cannot be used to skip the cache. Before 0.3.9 only `Authorization` did, and a plain `@cache` on a route that read the session - served one visitor's response to the next (#319). + served one visitor's response to the next (#319). The first bypass on each + route is logged at `WARNING` (see + [Requests with credentials](#requests-with-credentials)). - **The handler's own `Cache-Control` contains `private` or `no-store`** (as whole directives, in any case). The response is served but not stored, and the handler's header is sent unchanged instead of the decorator's. @@ -157,6 +159,53 @@ route's response model (declared or inferred from the return annotation, with the `response_model_*` options), the route's `status_code` applies, and the status and headers set on an injected `response: Response` parameter are kept. +### Requests with credentials + +A single-page app that sends `Authorization` on every request, or a site where +every visitor has a session, gets no cache hits at all on a plain `@cache` +route: each request bypasses the backend (see above). Pick the option that +matches what the handler returns: + +- **The response is the same for every user** (a product list, a public + article): set `public=True`. Requests with `Authorization` or a session then + read and write the backend like any other. Note that `public=True` also + changes the header sent downstream to `Cache-Control: public, ...`, which + tells a CDN or reverse proxy that it may store the response even though the + request carried credentials. Only use it when that is true. +- **The response is per user** (a profile, a cart, a dashboard): set + `cache_authorized=True` together with a `key_builder` that puts the verified + caller's identity into the key, so each user gets their own entry (see + [Authenticated endpoints](#authenticated-endpoints)). Without the identity in + the key, one user's response is served to the next. If you do not need a + server-side cache for it, leave both options unset (or use `private=True`) + and let only the browser cache it. + +So that a 0% hit rate does not go unnoticed, the first request that bypasses +a route because of a credential is logged once at `WARNING` on the +`fastapi_cachex.cache` logger, naming the route template (such as +`'/items/{item_id}'`), the credential that caused it (an `Authorization` +header, a session token, or non-empty `request.session` data) and the two +options above: + +```text +@cache bypassed the shared backend for route '/products': the request carried an Authorization header, so the response is not cached and is sent with Cache-Control: private. If the response is the same for every user, set @cache(public=True) (this also sends Cache-Control: public, so shared caches downstream may store it). If it is per user, set cache_authorized=True with a key_builder that puts the verified caller's identity into the key. Logged once per route and credential; each bypass is logged at DEBUG. +``` + +The warning is logged once per route and credential kind for the life of the +process (so once per worker), never with a header value or token. Every +bypass is still logged at `DEBUG`. When the bypass is what you want, set +`private=True` on the route, which bypasses the backend without a warning, or +raise the logger's level: + +```python +import logging + +logging.getLogger("fastapi_cachex.cache").setLevel(logging.ERROR) +``` + +That also hides the backend-failure warnings described below, so prefer +`private=True` where it fits. + ### When the backend fails `@cache` fails open. If the backend raises while reading, for example because diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index f628117..cbfd75b 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -3,6 +3,7 @@ import hashlib import inspect import logging +import threading import warnings from collections.abc import Awaitable from collections.abc import Callable @@ -254,6 +255,66 @@ def _request_credential(request: Request) -> str | None: ) +# How the one-time bypass warning names each `_request_credential` result. +_CREDENTIAL_DESCRIPTIONS = { + "Authorization header": "an Authorization header", + "Session": "a session token (header, bearer token or cookie)", + "Session data": "non-empty session data (request.session)", +} + +_BYPASS_WARNING = ( + "@cache bypassed the shared backend for route %r: the request carried %s, " + "so the response is not cached and is sent with Cache-Control: private. " + "If the response is the same for every user, set @cache(public=True) " + "(this also sends Cache-Control: public, so shared caches downstream may " + "store it). If it is per user, set cache_authorized=True with a key_builder " + "that puts the verified caller's identity into the key. Logged once per " + "route and credential; each bypass is logged at DEBUG." +) + + +class _BypassWarner: + """Log the credential bypass at ``WARNING`` once per route and credential. + + One instance lives in each ``@cache``-decorated function, so the state + goes away with the function (and a test's fresh app starts with none). + The route is keyed by its template (``/items/{item_id}``), never by the + requested path, so the set stays bounded by the routes the function is + registered on and client-chosen paths cannot grow it. + """ + + def __init__(self, fallback_name: str) -> None: + self._fallback_name = fallback_name + self._warned: set[tuple[str, str]] = set() + # The wrapper runs on an event loop, where check-then-add cannot be + # interleaved, but one decorated function can serve apps running on + # several loops in different threads (several servers in one process, + # each `TestClient`), and a set's check-then-add is not atomic across + # threads. The lock keeps "once" exact; it costs nothing on the hot + # path, which returns before taking it once the pair is recorded. + self._lock = threading.Lock() + + def __call__(self, request: Request, credential: str) -> None: + route_path = getattr(request.scope.get("route"), "path", None) + # Without a matched route (not reachable through FastAPI's router), + # name the handler: the requested path is client input and unbounded. + route = route_path if isinstance(route_path, str) else self._fallback_name + pair = (route, credential) + if pair in self._warned: + return + with self._lock: + if pair in self._warned: + return + self._warned.add(pair) + # Only the route template and the credential kind: never a header + # value or token. `%r` escapes anything unusual in the template. + logger.warning( + _BYPASS_WARNING, + route, + _CREDENTIAL_DESCRIPTIONS.get(credential, credential), + ) + + def default_key_builder(request: Request) -> str: """Default cache key builder function: ``build_cache_key(request)``. @@ -859,6 +920,9 @@ def cache( backend as ``private=True`` does (RFC 9111 §3.5), unless ``public`` is set, and its response is sent with ``private``. A token that does not resolve to a session does not count. + The first such bypass is logged at ``WARNING`` once per route + and credential kind (the route template and the kind, never the + value), since it otherwise leaves the route with no cache hits. RFC 9111 would also allow reuse under ``must-revalidate``, but ``must_revalidate=True`` does not lift the bypass: only this explicit opt-in or ``public`` does. Set this only when @@ -1002,6 +1066,9 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable: # routes skip the backend like private ones. `ttl=0` is included, since # `max-age=0` allows no reuse either. bypass_backend = private or not ttl + warn_bypass = _BypassWarner( + f"{getattr(func, '__module__', '?')}.{getattr(func, '__qualname__', '?')}" + ) @wraps(func) async def serve(*args: Any, **kwargs: Any) -> Response: @@ -1050,12 +1117,13 @@ async def serve(*args: Any, **kwargs: Any) -> Response: else _request_credential(req) ) authorized_bypass = credential is not None - if authorized_bypass: + if credential is not None: logger.debug( "%s present; bypassing the backend for path=%s", credential, req.url.path, ) + warn_bypass(req, credential) # A private response belongs to exactly one user, so it must never # be read from or written to the shared backend — the default cache diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 76b87f5..26049ee 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -93,7 +93,7 @@ async def items(): ... 屬於單一呼叫者的回應同樣不會被儲存(#296): -- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限,設定 `cache_authorized=True` 的路由也一樣;後者是給包含呼叫者身分的 key builder 使用的明確選項(見[需驗證身分的端點](#authenticated-endpoints))。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。 +- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限,設定 `cache_authorized=True` 的路由也一樣;後者是給包含呼叫者身分的 key builder 使用的明確選項(見[需驗證身分的端點](#authenticated-endpoints))。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。每個路由第一次繞過時會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。 - **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。 - **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。 @@ -101,6 +101,29 @@ async def items(): ... handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cache` 時相同:回傳值會經過路由的 response model 驗證與過濾(明確宣告的,或由回傳型別註記推斷,並套用 `response_model_*` 選項),套用路由的 `status_code`,而在注入的 `response: Response` 參數上設定的狀態碼與標頭也會保留。 +### 帶有憑證的請求 {#requests-with-credentials} + +每個請求都送出 `Authorization` 的單頁應用程式,或每位訪客都有 Session 的網站,在只加上 `@cache` 的路由上完全不會命中快取:每個請求都會繞過後端(見上文)。請依 handler 回傳的內容選擇: + +- **每位使用者得到相同的回應**(商品列表、公開文章):設定 `public=True`。帶有 `Authorization` 或 Session 的請求就會像其他請求一樣讀寫後端。注意 `public=True` 也會把送往下游的標頭改為 `Cache-Control: public, ...`,告訴 CDN 或反向 proxy 即使請求帶有憑證也可以儲存這個回應。只有在這確實成立時才使用它。 +- **回應依使用者而不同**(個人資料、購物車、儀表板):設定 `cache_authorized=True`,並搭配把已驗證的呼叫者身分放進鍵的 `key_builder`,讓每位使用者擁有自己的項目(見[需驗證身分的端點](#authenticated-endpoints))。鍵中沒有身分時,一位使用者的回應會提供給下一位。若不需要伺服器端快取,兩個選項都不要設定(或使用 `private=True`),只讓瀏覽器快取它。 + +為了不讓 0% 的命中率無人察覺,路由第一次因憑證而繞過後端時,會在 `fastapi_cachex.cache` logger 上以 `WARNING` 等級記錄一次,內容包含路由樣板(例如 `'/items/{item_id}'`)、造成繞過的憑證(`Authorization` 標頭、Session 權杖,或不是空的 `request.session` 資料),以及上述兩個選項: + +```text +@cache bypassed the shared backend for route '/products': the request carried an Authorization header, so the response is not cached and is sent with Cache-Control: private. If the response is the same for every user, set @cache(public=True) (this also sends Cache-Control: public, so shared caches downstream may store it). If it is per user, set cache_authorized=True with a key_builder that puts the verified caller's identity into the key. Logged once per route and credential; each bypass is logged at DEBUG. +``` + +這個警告在行程的生命週期內,每個路由與憑證種類只記錄一次(因此每個 worker 一次),而且絕不包含標頭值或權杖。每次繞過仍會以 `DEBUG` 等級記錄。若繞過正是你要的,請在路由上設定 `private=True`,它會繞過後端而不發出警告;或是提高 logger 的等級: + +```python +import logging + +logging.getLogger("fastapi_cachex.cache").setLevel(logging.ERROR) +``` + +這也會隱藏下文所述的後端錯誤警告,因此適用時請優先使用 `private=True`。 + ### 後端發生錯誤時 {#when-the-backend-fails} `@cache` 採取 fail open。讀取時後端拋出錯誤(例如 Redis 或 Memcached 無法連線),該請求會被當成快取未命中,照常執行 handler。儲存回應時拋出錯誤(例如回應超過 Memcached 的項目大小上限,預設為 1 MB),回應會照常送出,只是不會被儲存。兩種情況都會在 `fastapi_cachex.cache` logger 記錄一則警告,因此後端中斷不會讓有快取的路由變成 500;負載會轉到你的 handler 上,請留意這些警告。 diff --git a/tests/session/test_cache_bypass_warning.py b/tests/session/test_cache_bypass_warning.py new file mode 100644 index 0000000..0b29d82 --- /dev/null +++ b/tests/session/test_cache_bypass_warning.py @@ -0,0 +1,306 @@ +"""`@cache` warns once when a credential makes it bypass the backend (#326). + +The bypass for ``Authorization`` and session requests used to be logged only +at ``DEBUG``, so a route that never hit the cache gave no sign of why. The +first bypass per route and credential kind is now logged at ``WARNING``. The +"once" state lives in each decorated function, so every test's fresh app +starts without it. +""" + +import logging + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi import Response +from fastapi.testclient import TestClient +from starlette.middleware.sessions import ( + SessionMiddleware as StarletteSessionMiddleware, +) + +from fastapi_cachex import cache +from fastapi_cachex.cache import _BypassWarner +from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.manager import SessionManager +from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware +from fastapi_cachex.session.models import SessionUser + +_LOGGER = "fastapi_cachex.cache" +_BEARER = "Bearer secret-bearer-value-326" + + +def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [ + r.getMessage() + for r in caplog.records + if r.name == _LOGGER and r.levelno == logging.WARNING + ] + + +def _app( + manager: SessionManager | None = None, + config: SessionConfig | None = None, + **cache_kwargs: object, +) -> FastAPI: + """Two cached routes and a route that writes the session.""" + app = FastAPI() + if manager is not None: + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) + else: + app.add_middleware(StarletteSessionMiddleware, secret_key="s" * 32) + + @app.get("/products") + @cache(ttl=60, **cache_kwargs) # type: ignore[arg-type] + async def products() -> dict[str, str]: + return {"products": "all"} + + @app.get("/items/{item_id}") + @cache(ttl=60, **cache_kwargs) # type: ignore[arg-type] + async def item(item_id: int) -> dict[str, int]: + return {"item": item_id} + + @app.get("/add") + async def add(request: Request) -> dict[str, bool]: + request.session["cart"] = ["apple"] + return {"ok": True} + + return app + + +async def _token(manager: SessionManager) -> str: + _session, token = await manager.create_session(user=SessionUser(user_id="alice")) + return token + + +async def _client( + kind: str, manager: SessionManager, config: SessionConfig, **cache_kwargs: object +) -> tuple[TestClient, str]: + """A client whose every request carries credential ``kind``, and its secret.""" + if kind == "authorization": + app = _app(manager, config, **cache_kwargs) + return TestClient(app, headers={"Authorization": _BEARER}), _BEARER + if kind == "session header": + token = await _token(manager) + app = _app(manager, config, **cache_kwargs) + return TestClient(app, headers={config.header_name: token}), token + if kind == "session cookie": + token = await _token(manager) + app = _app(manager, config, **cache_kwargs) + return TestClient(app, cookies={config.cookie_name: token}), token + # Starlette's cookie session with data in it. + client = TestClient(_app(None, None, **cache_kwargs)) + client.get("/add") + return client, client.cookies["session"] + + +_KINDS = { + "authorization": "an Authorization header", + "session header": "a session token (header, bearer token or cookie)", + "session cookie": "a session token (header, bearer token or cookie)", + "session data": "non-empty session data (request.session)", +} + + +@pytest.mark.parametrize("kind", list(_KINDS)) +async def test_first_bypass_warns_once( + kind: str, + manager: SessionManager, + config: SessionConfig, + caplog: pytest.LogCaptureFixture, +) -> None: + client, secret = await _client(kind, manager, config) + + with caplog.at_level(logging.DEBUG, logger=_LOGGER): + for _ in range(3): + response = client.get("/products") + assert response.headers["Cache-Control"] == "private, max-age=60" + + [warning] = _warnings(caplog) + assert "route '/products'" in warning + assert f"the request carried {_KINDS[kind]}," in warning + assert "public=True" in warning + assert "cache_authorized=True" in warning + assert "key_builder" in warning + # Every request still gets its DEBUG line. + debug = [r for r in caplog.records if "bypassing the backend" in r.getMessage()] + assert len(debug) == 3 + # Neither the token nor the header value reaches any log line. + assert secret not in caplog.text + assert "secret-bearer-value" not in caplog.text + + +async def test_each_route_warns( + manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture +) -> None: + client, _ = await _client("authorization", manager, config) + + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for _ in range(2): + client.get("/products") + client.get("/items/1") + + warnings = _warnings(caplog) + assert len(warnings) == 2 + assert "route '/products'" in warnings[0] + assert "route '/items/{item_id}'" in warnings[1] + + +async def test_route_is_named_by_its_template( + manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture +) -> None: + """Distinct paths of one route warn once, and no requested path is logged.""" + client, _ = await _client("authorization", manager, config) + + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for item_id in range(5): + client.get(f"/items/{item_id}") + + [warning] = _warnings(caplog) + assert "route '/items/{item_id}'" in warning + assert "/items/3" not in warning + + +async def test_each_credential_kind_warns( + manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture +) -> None: + token = await _token(manager) + client = TestClient(_app(manager, config)) + + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for _ in range(2): + client.get("/products", headers={"Authorization": _BEARER}) + client.get("/products", headers={config.header_name: token}) + + warnings = _warnings(caplog) + assert len(warnings) == 2 + assert _KINDS["authorization"] in warnings[0] + assert _KINDS["session header"] in warnings[1] + + +@pytest.mark.parametrize("opt_in", ["public", "cache_authorized"]) +@pytest.mark.parametrize("kind", list(_KINDS)) +async def test_opted_in_routes_do_not_warn( + opt_in: str, + kind: str, + manager: SessionManager, + config: SessionConfig, + caplog: pytest.LogCaptureFixture, +) -> None: + client, _ = await _client(kind, manager, config, **{opt_in: True}) + + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for _ in range(2): + client.get("/products") + + assert _warnings(caplog) == [] + + +async def test_requests_without_credentials_do_not_warn( + manager: SessionManager, config: SessionConfig, caplog: pytest.LogCaptureFixture +) -> None: + client = TestClient(_app(manager, config)) + + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for _ in range(3): + client.get("/products") + client.get("/items/1") + + assert _warnings(caplog) == [] + + +async def test_private_routes_do_not_warn(caplog: pytest.LogCaptureFixture) -> None: + """``private=True`` bypasses on its own; a credential changes nothing there.""" + app = FastAPI() + + @app.get("/me") + @cache(ttl=60, private=True) + async def me() -> dict[str, str]: + return {"me": "x"} + + client = TestClient(app, headers={"Authorization": _BEARER}) + with caplog.at_level(logging.WARNING, logger=_LOGGER): + client.get("/me") + + assert _warnings(caplog) == [] + + +async def test_without_a_route_the_handler_is_named( + caplog: pytest.LogCaptureFixture, +) -> None: + """Called outside the router, the warning names the handler, not the path.""" + + @cache(ttl=60) + async def handler(request: Request) -> Response: + return Response("ok") + + scope = { + "type": "http", + "method": "GET", + "path": "/client/chosen", + "query_string": b"", + "headers": [(b"authorization", _BEARER.encode())], + } + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for _ in range(2): + await handler(request=Request(scope)) + + [warning] = _warnings(caplog) + assert "test_without_a_route_the_handler_is_named..handler" in warning + assert "/client/chosen" not in warning + + +def test_a_pair_recorded_by_another_thread_is_not_logged_again( + caplog: pytest.LogCaptureFixture, +) -> None: + """The check under the lock catches a thread that won between check and add.""" + warner = _BypassWarner("handler") + + class _LosesTheRace(set[tuple[str, str]]): + """Misses on the unlocked check, as if another thread added it just after.""" + + def __contains__(self, item: object) -> bool: + if not getattr(self, "raced", False): + self.raced = True + assert isinstance(item, tuple) + self.add(item) + return False + return super().__contains__(item) + + warner._warned = _LosesTheRace() + scope = { + "type": "http", + "method": "GET", + "path": "/x", + "query_string": b"", + "headers": [], + } + with caplog.at_level(logging.WARNING, logger=_LOGGER): + warner(Request(scope), "Authorization header") + + assert _warnings(caplog) == [] + + +async def test_one_handler_on_two_routes_warns_for_each( + caplog: pytest.LogCaptureFixture, +) -> None: + """The state is per decorated function, and keyed by route inside it.""" + app = FastAPI() + + @app.get("/a") + @app.get("/b") + @cache(ttl=60) + async def shared() -> dict[str, str]: + return {"ok": "yes"} + + client = TestClient(app, headers={"Authorization": _BEARER}) + with caplog.at_level(logging.WARNING, logger=_LOGGER): + for _ in range(2): + client.get("/a") + client.get("/b") + + warnings = _warnings(caplog) + assert len(warnings) == 2 + assert "route '/a'" in warnings[0] + assert "route '/b'" in warnings[1]