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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Rate limiting and role guard configuration."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import ClassVar
8from lexigram.config import BaseConfig
9from lexigram.validation import ConfigDict, Field, model_validator
10from lexigram.web import constants as const
13@dataclass(init=False)
14class RateLimitRuleConfig(BaseConfig):
15 """Rate limit rule for a specific path pattern."""
17 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
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
32@dataclass(init=False)
33class RateLimitConfig(BaseConfig):
34 """Rate limiting configuration with per-path rules."""
36 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
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 )
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
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]
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)
91 return best_match
94@dataclass(init=False)
95class RoleGuardRuleConfig(BaseConfig):
96 """One role guard rule entry from ``web.role_guard.rules``.
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 """
104 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
115@dataclass(init=False)
116class RoleGuardConfig(BaseConfig):
117 """Role guard settings from ``web.role_guard``.
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]}]``).
122 Attributes:
123 rules: Rules applied in declaration order; first match wins.
124 """
126 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
128 rules: list[RoleGuardRuleConfig] = Field(
129 default_factory=list,
130 description="Role guard rules in declaration order",
131 )
133 @property
134 def enabled(self) -> bool:
135 """Return True when at least one rule is declared."""
136 return bool(self.rules)
139__all__ = [
140 "RateLimitConfig",
141 "RateLimitRuleConfig",
142 "RoleGuardConfig",
143 "RoleGuardRuleConfig",
144]