Coverage for src/lexigram/graphql/decorators.py: 100%
37 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Decorators for GraphQL resolvers — retry and structured logging."""
3from __future__ import annotations
5import asyncio
6from collections.abc import Callable
7import functools
8from typing import Any, TypeVar
10from lexigram.logging import get_logger
12logger = get_logger(__name__)
14F = TypeVar("F", bound=Callable[..., Any])
16__all__ = [
17 "log_resolver",
18 "retry_resolver",
19]
22def retry_resolver(
23 max_retries: int = 3,
24 *,
25 delay: float = 0.1,
26 exceptions: tuple[type[Exception], ...] = (Exception,),
27) -> Callable[[F], F]:
28 """Wrap an async GraphQL resolver with automatic retry logic.
30 Retries the decorated resolver up to *max_retries* times when any of the
31 specified *exceptions* are raised. Uses exponential back-off between
32 attempts: ``delay * 2 ** attempt`` seconds.
34 Args:
35 max_retries: Maximum number of attempts before re-raising the last
36 exception. Must be at least 1.
37 delay: Base delay in seconds between retries. Doubles on each attempt.
38 exceptions: Tuple of exception types that trigger a retry. Defaults
39 to ``(Exception,)`` — any exception retries.
41 Returns:
42 Decorator that wraps an async resolver with retry machinery.
44 Example::
46 @strawberry.type
47 class Query:
48 @retry_resolver(max_retries=3, delay=0.2, exceptions=(TimeoutError,))
49 async def user(self, info: Info, id: str) -> User:
50 return await fetch_user(id)
51 """
53 def decorator(fn: F) -> F:
54 @functools.wraps(fn)
55 async def wrapper(*args: Any, **kwargs: Any) -> Any:
56 last_exc: BaseException | None = None
57 for attempt in range(max_retries):
58 try:
59 return await fn(*args, **kwargs)
60 except exceptions as exc: # noqa: BLE001
61 last_exc = exc
62 if attempt < max_retries - 1:
63 await asyncio.sleep(delay * (2**attempt))
64 logger.warning(
65 "resolver_retry",
66 resolver=fn.__qualname__,
67 attempt=attempt + 1,
68 max_retries=max_retries,
69 error=str(exc),
70 )
71 raise last_exc from last_exc # type: ignore[misc]
73 return wrapper # type: ignore[return-value]
75 return decorator
78def log_resolver(fn: F) -> F:
79 """Wrap an async GraphQL resolver with structured entry/exit/error logging.
81 Emits debug-level log events on entry and exit, and an error-level event
82 when an exception propagates out of the resolver. The exception is always
83 re-raised — this decorator never swallows errors.
85 Args:
86 fn: The async resolver function to wrap.
88 Returns:
89 Wrapped resolver with structured logging applied.
91 Example::
93 @strawberry.type
94 class Mutation:
95 @log_resolver
96 async def create_user(self, info: Info, input: CreateUserInput) -> User:
97 return await service.create(input)
98 """
100 @functools.wraps(fn)
101 async def wrapper(*args: Any, **kwargs: Any) -> Any:
102 logger.debug("resolver_enter", resolver=fn.__qualname__)
103 try:
104 result = await fn(*args, **kwargs)
105 logger.debug("resolver_exit", resolver=fn.__qualname__)
106 return result
107 except Exception as exc: # noqa: BLE001
108 logger.error(
109 "resolver_error",
110 resolver=fn.__qualname__,
111 error=str(exc),
112 error_type=type(exc).__name__,
113 )
114 raise
116 return wrapper # type: ignore[return-value]