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

1"""Decorators for GraphQL resolvers — retry and structured logging.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from collections.abc import Callable 

7import functools 

8from typing import Any, TypeVar 

9 

10from lexigram.logging import get_logger 

11 

12logger = get_logger(__name__) 

13 

14F = TypeVar("F", bound=Callable[..., Any]) 

15 

16__all__ = [ 

17 "log_resolver", 

18 "retry_resolver", 

19] 

20 

21 

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. 

29 

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. 

33 

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. 

40 

41 Returns: 

42 Decorator that wraps an async resolver with retry machinery. 

43 

44 Example:: 

45 

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 """ 

52 

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] 

72 

73 return wrapper # type: ignore[return-value] 

74 

75 return decorator 

76 

77 

78def log_resolver(fn: F) -> F: 

79 """Wrap an async GraphQL resolver with structured entry/exit/error logging. 

80 

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. 

84 

85 Args: 

86 fn: The async resolver function to wrap. 

87 

88 Returns: 

89 Wrapped resolver with structured logging applied. 

90 

91 Example:: 

92 

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 """ 

99 

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 

115 

116 return wrapper # type: ignore[return-value]