Source code for dmr.test.throttling

import contextlib
import dataclasses
from collections.abc import Callable, Generator
from http import HTTPMethod, HTTPStatus
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, assert_never

from django.http import HttpRequest, HttpResponse, HttpResponseBase

from dmr.response import infer_status_code
from dmr.test.client import DMRAsyncRequestFactory
from dmr.throttling import AsyncThrottle, Rate, SyncThrottle

if TYPE_CHECKING:
    from dmr.controller import Controller
    from dmr.serializer import BaseSerializer

ThrottlingWhen: TypeAlias = Literal['any', 'before_auth', 'after_auth']

_default_any_when: Final = 'any'


[docs] def assert_throttling( # noqa: WPS210 controller_cls: type['Controller[BaseSerializer]'], request_factory: Callable[[], HttpRequest], *, max_requests: int = 2, rate: Rate = Rate.hour, when: ThrottlingWhen = _default_any_when, success_status: HTTPStatus | None = None, ) -> tuple[HttpResponse, SyncThrottle]: """ Reduce the first throttle, drive requests, and assert the ``429``. Sends *max_requests* allowed requests, then one that is rejected, and checks it with :func:`assert_throttled` -- including every header the reduced throttle reports. Returns the ``429`` response, so you can make further assertions on it. Parameters *when* and *rate* are passed down to :func:`reduced_throttling` and :func:`assert_throttled`. .. versionadded:: 0.12.0 """ view = controller_cls.as_view() request = request_factory() method: str = request.method # type: ignore[assignment] with reduced_throttling( controller_cls, method=method, max_requests=max_requests, rate=rate, when=when, ) as throttle: assert isinstance(throttle, SyncThrottle), throttle for index in range(max_requests): _assert_ok(view(request), success_status, method) if index != max_requests - 1: # Build new request instance for new attempt: request = request_factory() throttled = _as_response(view(request_factory())) assert_throttled(throttled, throttle=throttle) return throttled, throttle # noqa: WPS441
[docs] async def assert_async_throttling( # noqa: WPS210 controller_cls: type['Controller[BaseSerializer]'], request_factory: Callable[[], HttpRequest], *, max_requests: int = 2, rate: Rate = Rate.hour, when: ThrottlingWhen = _default_any_when, success_status: HTTPStatus | None = None, ) -> tuple[HttpResponse, AsyncThrottle]: """ Async version of :func:`assert_throttling`. .. versionadded:: 0.12.0 """ view = controller_cls.as_view() request = request_factory() method: str = request.method # type: ignore[assignment] wrap = DMRAsyncRequestFactory().wrap with reduced_throttling( controller_cls, method=method, max_requests=max_requests, rate=rate, when=when, ) as throttle: assert isinstance(throttle, AsyncThrottle), throttle for index in range(max_requests): _assert_ok(await wrap(view(request)), success_status, method) # noqa: WPS476 if index != max_requests - 1: # Build new request instance for new attempt: request = request_factory() throttled = _as_response( await wrap(view(request_factory())), ) assert_throttled(throttled, throttle=throttle) return throttled, throttle # noqa: WPS441
[docs] @contextlib.contextmanager def reduced_throttling( controller_cls: type['Controller[BaseSerializer]'], *, method: HTTPMethod | str, max_requests: int = 2, rate: Rate = Rate.hour, when: ThrottlingWhen = _default_any_when, ) -> Generator[SyncThrottle | AsyncThrottle]: """ Temporarily lower an endpoint's first throttle so a test can reach it. Replaces the first throttle of the chosen *when* with a copy limited to *max_requests*, so a few real requests trip it instead of the configured rate. Only the endpoint under test is affected; the original throttling is restored on exit. Yields the reduced throttle, whose *max_requests* tells you how many allowed requests to send before the next one is rejected. The copy also has its window widened to *rate*, because lowering just *max_requests* is not enough for short windows: a ``2/second`` throttle would reset between the driven requests and the endpoint would never be rejected. With an hour-long window every request a test sends lands inside the same window. Parameters: controller_cls: Controller whose endpoint is under test. method: HTTP method of the endpoint to target. max_requests: Limit the throttle is lowered to (``2`` by default). The window is always widened to one hour, see above. rate: Time window to narrow to (:attr:`~dmr.throttling.Rate.hour` by default). when: When to run the throttle -- ``'before_auth'`` (checked before auth, e.g. by IP), ``'after_auth'`` (per-user, needs an authenticated request), or ``'any'`` (default, the first one checked). .. versionadded:: 0.12.0 """ endpoint = controller_cls.api_endpoints.get(str(method).upper()) if endpoint is None: raise ValueError( f'{controller_cls.__qualname__} has no endpoint ' f'for method {method!r}', ) metadata = endpoint.metadata match when: case 'before_auth': throttles = metadata.throttling_before_auth case 'after_auth': throttles = metadata.throttling_after_auth case 'any': throttles = metadata.throttling case _: assert_never(when) if not throttles: raise ValueError( f'Endpoint {metadata.operation_id} has no throttling ' f'to test for {when!r}', ) original = throttles[0] reduced = original.replace( max_requests=max_requests, duration_in_seconds=rate, ) # Swap the throttle in a fresh metadata; restored in `finally` below: endpoint.metadata = dataclasses.replace( metadata, throttling_before_auth=_swap( metadata.throttling_before_auth, original, reduced, ), throttling_after_auth=_swap( metadata.throttling_after_auth, original, reduced, ), ) try: yield reduced finally: endpoint.metadata = metadata
[docs] def assert_throttled( response: HttpResponse, *, throttle: SyncThrottle | AsyncThrottle | None = None, ) -> None: """ Assert that a response was rejected by throttling. Collapses the repeated ``429`` status, header and error-body checks into a single call: .. code:: python # Just the status + `ratelimit` error: assert_throttled(response) # Also asserts all the headers from the throttle instance exist: assert_throttled(response, throttle=your_throttled_instance) Parameters: response: The response to check. throttle: Throttle that rejected the request. When given, every header its providers report must be present in the response. *throttle* instance defines which headers are reported depends on the header provider's class: :class:`~dmr.throttling.headers.XRateLimit`, :class:`~dmr.throttling.headers.RateLimitIETFDraft`, or your own provider. .. versionadded:: 0.12.0 """ assert response.status_code == HTTPStatus.TOO_MANY_REQUESTS, ( response.content ) if throttle is not None: _assert_reported_headers(throttle, response.headers)
def _swap( throttles: list[SyncThrottle | AsyncThrottle] | None, old: SyncThrottle | AsyncThrottle, new: SyncThrottle | AsyncThrottle, ) -> list[SyncThrottle | AsyncThrottle] | None: if not throttles: return throttles return [new if throttle is old else throttle for throttle in throttles] def _assert_reported_headers( throttle: SyncThrottle | AsyncThrottle, headers: Any, ) -> None: for provider in throttle.response_headers: for name in provider.provide_headers_specs(): assert name in headers, ( f'Header {name!r} reported by ' f'{type(provider).__qualname__} is missing, ' f'present headers: {dict(headers)!r}' ) def _as_response(candidate: HttpResponseBase) -> HttpResponse: assert isinstance(candidate, HttpResponse), candidate return candidate def _assert_ok( candidate: HttpResponseBase, status: int | None, method: str, ) -> None: success_status = status or infer_status_code(method) response = _as_response(candidate) assert response.status_code == success_status, response.content