Coverage for src/lexigram/web/config/rate_limit.py: 69%

55 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Rate limiting and role guard configuration.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import ClassVar 

7 

8from lexigram.config import BaseConfig 

9from lexigram.validation import ConfigDict, Field, model_validator 

10from lexigram.web import constants as const 

11 

12 

13@dataclass(init=False) 

14class RateLimitRuleConfig(BaseConfig): 

15 """Rate limit rule for a specific path pattern.""" 

16 

17 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

18 

19 requests: int = Field(default=100, ge=1, description="Max requests per window") 

20 window: int = Field(default=60, ge=1, description="Window size in seconds") 

21 burst: int | None = Field( 

22 default=None, 

23 description="Burst capacity (defaults to requests)", 

24 ) 

25 

26 @property 

27 def effective_burst(self) -> int: 

28 """Get burst capacity, defaulting to requests if not set.""" 

29 return self.burst if self.burst is not None else self.requests 

30 

31 

32@dataclass(init=False) 

33class RateLimitConfig(BaseConfig): 

34 """Rate limiting configuration with per-path rules.""" 

35 

36 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

37 

38 enabled: bool = Field( 

39 default=True, 

40 description=( 

41 "Enable rate limiting. When true, RateLimitMiddleware enforces " 

42 "the matched per-path rule or the default_limit/default_window " 

43 "on every HTTP request." 

44 ), 

45 ) 

46 default_limit: int = Field( 

47 default=const.DEFAULT_RATE_LIMIT_REQUESTS, description="Max requests per window" 

48 ) 

49 default_window: int = Field( 

50 default=const.DEFAULT_RATE_LIMIT_WINDOW, description="Window size in seconds" 

51 ) 

52 whitelist_ips: list[str] = Field( 

53 default_factory=list, 

54 description="Exempt IP addresses", 

55 ) 

56 storage_backend: str = Field( 

57 default="memory", 

58 description="Storage backend (memory/redis)", 

59 ) 

60 

61 # Per-path rules — enforced by RateLimitMiddleware via get_rule() 

62 rules: dict[str, RateLimitRuleConfig] = Field( 

63 default_factory=dict, 

64 description="Per-path rate limit rules; longest-prefix match wins", 

65 ) 

66 

67 @model_validator(mode="after") 

68 def validate_rate_limit(self) -> RateLimitConfig: 

69 """Validate rate limit settings.""" 

70 if self.enabled: 

71 if self.default_limit <= 0: 

72 raise ValueError("Rate limit 'default_limit' must be greater than 0.") 

73 if self.default_window <= 0: 

74 raise ValueError("Rate limit 'default_window' must be greater than 0.") 

75 return self 

76 

77 def get_rule(self, path: str) -> RateLimitRuleConfig | None: 

78 """Get rate limit rule for a path (longest prefix match).""" 

79 # Exact match first 

80 if path in self.rules: 

81 return self.rules[path] 

82 

83 # Longest prefix match 

84 best_match = None 

85 best_length = 0 

86 for pattern, rule in self.rules.items(): 

87 if path.startswith(pattern) and len(pattern) > best_length: 

88 best_match = rule 

89 best_length = len(pattern) 

90 

91 return best_match 

92 

93 

94@dataclass(init=False) 

95class RoleGuardRuleConfig(BaseConfig): 

96 """One role guard rule entry from ``web.role_guard.rules``. 

97 

98 Attributes: 

99 path: Exact path to guard. A trailing ``/**`` matches every path 

100 under that prefix. 

101 roles: Role identifiers allowed to pass. 

102 """ 

103 

104 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

105 

106 path: str = Field( 

107 default="", description="Path to guard ('/**' suffix matches the prefix)" 

108 ) 

109 roles: list[str] = Field( 

110 default_factory=list, 

111 description="Role identifiers allowed to pass", 

112 ) 

113 

114 

115@dataclass(init=False) 

116class RoleGuardConfig(BaseConfig): 

117 """Role guard settings from ``web.role_guard``. 

118 

119 Absent by default; a single gating rule is enough for most applications 

120 (e.g. ``web.role_guard.rules: [{path: /api/users, roles: [admin]}]``). 

121 

122 Attributes: 

123 rules: Rules applied in declaration order; first match wins. 

124 """ 

125 

126 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

127 

128 rules: list[RoleGuardRuleConfig] = Field( 

129 default_factory=list, 

130 description="Role guard rules in declaration order", 

131 ) 

132 

133 @property 

134 def enabled(self) -> bool: 

135 """Return True when at least one rule is declared.""" 

136 return bool(self.rules) 

137 

138 

139__all__ = [ 

140 "RateLimitConfig", 

141 "RateLimitRuleConfig", 

142 "RoleGuardConfig", 

143 "RoleGuardRuleConfig", 

144]