api-service-handler 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_service_handler/__init__.py +77 -0
- api_service_handler/cli.py +341 -0
- api_service_handler/client.py +868 -0
- api_service_handler/config.py +177 -0
- api_service_handler/encryption.py +238 -0
- api_service_handler/enums.py +217 -0
- api_service_handler/exceptions.py +184 -0
- api_service_handler/models.py +301 -0
- api_service_handler/py.typed +0 -0
- api_service_handler/rate_limiter.py +187 -0
- api_service_handler/rotation.py +163 -0
- api_service_handler/storage/__init__.py +7 -0
- api_service_handler/storage/base.py +243 -0
- api_service_handler/storage/memory.py +229 -0
- api_service_handler/storage/mongodb.py +432 -0
- api_service_handler/storage/postgresql.py +429 -0
- api_service_handler/storage/sqlite.py +511 -0
- api_service_handler/usage_tracker.py +219 -0
- api_service_handler/utils.py +322 -0
- api_service_handler-0.1.6.dist-info/METADATA +282 -0
- api_service_handler-0.1.6.dist-info/RECORD +23 -0
- api_service_handler-0.1.6.dist-info/WHEEL +4 -0
- api_service_handler-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Custom exceptions for the api-service-handler library.
|
|
2
|
+
|
|
3
|
+
Every exception inherits from :class:`APIServiceHandlerError` so callers
|
|
4
|
+
can catch the entire family with a single ``except`` clause when desired.
|
|
5
|
+
Each concrete exception stores structured context as instance attributes
|
|
6
|
+
and generates a clear, actionable error message.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIServiceHandlerError(Exception):
|
|
13
|
+
"""Base exception for all api-service-handler errors.
|
|
14
|
+
|
|
15
|
+
All library-specific exceptions inherit from this class, making it
|
|
16
|
+
easy to catch any error raised by the library::
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
key = await manager.get_key("openai")
|
|
20
|
+
except APIServiceHandlerError as exc:
|
|
21
|
+
logging.error("Service handler failure: %s", exc)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str = "An API service handler error occurred") -> None:
|
|
25
|
+
self.message = message
|
|
26
|
+
super().__init__(self.message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KeyNotFoundError(APIServiceHandlerError):
|
|
30
|
+
"""Raised when a key with the given ID does not exist in storage.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
key_id: The identifier that was looked up.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, key_id: str) -> None:
|
|
37
|
+
self.key_id = key_id
|
|
38
|
+
super().__init__(f"API key not found: '{key_id}'")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DuplicateKeyError(APIServiceHandlerError):
|
|
42
|
+
"""Raised when attempting to register a key that already exists.
|
|
43
|
+
|
|
44
|
+
A key is considered duplicate when the same ``key_value`` is already
|
|
45
|
+
registered for the given ``provider``.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
provider: The provider for which the duplicate was detected.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, provider: str, key_value: str | None = None) -> None:
|
|
52
|
+
self.provider = provider
|
|
53
|
+
self.key_value = key_value
|
|
54
|
+
msg = f"A key with the same value already exists for provider '{provider}'"
|
|
55
|
+
if key_value:
|
|
56
|
+
masked = key_value[:8] + "***" if len(key_value) > 8 else key_value[:2] + "***"
|
|
57
|
+
msg = f"Key '{masked}' already exists for provider '{provider}'"
|
|
58
|
+
super().__init__(msg)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RateLimitExceededError(APIServiceHandlerError):
|
|
62
|
+
"""Raised when a key has exceeded its daily or monthly usage limit.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
key_id: The key that hit the limit.
|
|
66
|
+
limit_type: Either ``'daily'`` or ``'monthly'``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
key_id: str,
|
|
72
|
+
limit_type: str,
|
|
73
|
+
limit: int | None = None,
|
|
74
|
+
current: int | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.key_id = key_id
|
|
77
|
+
self.limit_type = limit_type
|
|
78
|
+
self.limit = limit
|
|
79
|
+
self.current = current
|
|
80
|
+
msg = f"API key '{key_id}' has exceeded its {limit_type} rate limit"
|
|
81
|
+
if limit is not None:
|
|
82
|
+
msg += f" (limit: {limit}, current: {current})"
|
|
83
|
+
super().__init__(msg)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MaxConcurrentExceededError(APIServiceHandlerError):
|
|
87
|
+
"""Raised when a key's concurrent usage equals or exceeds its maximum.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
key_id: The key that is at capacity.
|
|
91
|
+
max_concurrent: The configured concurrency ceiling.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, key_id: str, max_concurrent: int) -> None:
|
|
95
|
+
self.key_id = key_id
|
|
96
|
+
self.max_concurrent = max_concurrent
|
|
97
|
+
super().__init__(
|
|
98
|
+
f"API key '{key_id}' has reached its maximum concurrent usage "
|
|
99
|
+
f"of {max_concurrent}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NoAvailableKeyError(APIServiceHandlerError):
|
|
104
|
+
"""Raised when no active key is available for the requested provider.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
provider: The provider for which no key could be found.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, provider: str) -> None:
|
|
111
|
+
self.provider = provider
|
|
112
|
+
super().__init__(
|
|
113
|
+
f"No available API key for provider '{provider}'"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class StorageConnectionError(APIServiceHandlerError):
|
|
118
|
+
"""Raised when the storage backend cannot be reached.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
backend: Name of the storage backend (e.g. ``'postgresql'``).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, backend: str, detail: str | None = None, details: str | None = None) -> None:
|
|
125
|
+
self.backend = backend
|
|
126
|
+
self.detail = detail or details
|
|
127
|
+
msg = f"Failed to connect to storage backend '{backend}'"
|
|
128
|
+
if self.detail:
|
|
129
|
+
msg += f": {self.detail}"
|
|
130
|
+
super().__init__(msg)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class EncryptionError(APIServiceHandlerError):
|
|
134
|
+
"""Raised when an encryption or decryption operation fails.
|
|
135
|
+
|
|
136
|
+
This may indicate a missing or invalid encryption key, corrupted
|
|
137
|
+
ciphertext, or an unsupported algorithm.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, detail: str = "Encryption/decryption operation failed") -> None:
|
|
141
|
+
self.detail = detail
|
|
142
|
+
super().__init__(detail)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class InvalidProviderError(APIServiceHandlerError):
|
|
146
|
+
"""Raised when an unrecognised provider string is used in a strict context.
|
|
147
|
+
|
|
148
|
+
Attributes:
|
|
149
|
+
provider: The invalid provider string that was supplied.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, provider: str) -> None:
|
|
153
|
+
self.provider = provider
|
|
154
|
+
super().__init__(
|
|
155
|
+
f"Invalid or unknown provider: '{provider}'"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class KeyExpiredError(APIServiceHandlerError):
|
|
160
|
+
"""Raised when an API key is past its ``expires_at`` timestamp.
|
|
161
|
+
|
|
162
|
+
Attributes:
|
|
163
|
+
key_id: The expired key's identifier.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, key_id: str) -> None:
|
|
167
|
+
self.key_id = key_id
|
|
168
|
+
super().__init__(
|
|
169
|
+
f"API key '{key_id}' has expired"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class StorageNotInitializedError(APIServiceHandlerError):
|
|
174
|
+
"""Raised when a storage operation is attempted before initialization.
|
|
175
|
+
|
|
176
|
+
Callers must invoke ``storage.initialize()`` (or its async counterpart)
|
|
177
|
+
before performing any read/write operations.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self) -> None:
|
|
181
|
+
super().__init__(
|
|
182
|
+
"Storage backend has not been initialized. "
|
|
183
|
+
"Call 'storage.initialize()' before performing operations."
|
|
184
|
+
)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Data models for API key management.
|
|
4
|
+
|
|
5
|
+
Provides Pydantic v2 models for API key storage, creation, update,
|
|
6
|
+
filtering, usage statistics, and bulk operation results.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import date, datetime, timezone
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from .enums import Environment, KeyStatus, Provider, RotationStrategy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Main model
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class APIKey(BaseModel):
|
|
24
|
+
"""Represents a single managed API key with usage tracking and metadata.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
id: Unique identifier (UUID) for this key entry.
|
|
28
|
+
provider: The service provider this key belongs to.
|
|
29
|
+
key_value: The actual API key string (may be encrypted at rest).
|
|
30
|
+
alias: Optional human-friendly name, e.g. ``'prod-openai-1'``.
|
|
31
|
+
status: Current lifecycle status of the key.
|
|
32
|
+
environment: Deployment environment the key is designated for.
|
|
33
|
+
daily_limit: Maximum allowed requests per day (``None`` = unlimited).
|
|
34
|
+
monthly_limit: Maximum allowed requests per month (``None`` = unlimited).
|
|
35
|
+
daily_usage_count: Number of requests made today.
|
|
36
|
+
monthly_usage_count: Number of requests made this month.
|
|
37
|
+
total_usage_count: Lifetime request count.
|
|
38
|
+
concurrent_usage: Number of in-flight requests right now.
|
|
39
|
+
max_concurrent: Maximum simultaneous in-flight requests (``None`` = unlimited).
|
|
40
|
+
weight: Relative weight for weighted-rotation strategies.
|
|
41
|
+
priority: Priority rank — lower values are selected first.
|
|
42
|
+
metadata: Arbitrary key/value pairs for user-defined data.
|
|
43
|
+
tags: Free-form labels for categorisation and filtering.
|
|
44
|
+
last_used_at: Timestamp of the most recent usage.
|
|
45
|
+
last_reset_daily: Date when daily counters were last zeroed.
|
|
46
|
+
last_reset_monthly: Date when monthly counters were last zeroed.
|
|
47
|
+
expires_at: Optional expiration timestamp.
|
|
48
|
+
created_at: Timestamp when this record was created.
|
|
49
|
+
updated_at: Timestamp when this record was last modified.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
53
|
+
|
|
54
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
55
|
+
provider: Provider
|
|
56
|
+
key_value: str
|
|
57
|
+
alias: Optional[str] = None
|
|
58
|
+
status: KeyStatus = KeyStatus.ACTIVE
|
|
59
|
+
environment: Environment = Environment.PRODUCTION
|
|
60
|
+
|
|
61
|
+
# Rate limits
|
|
62
|
+
daily_limit: Optional[int] = None
|
|
63
|
+
monthly_limit: Optional[int] = None
|
|
64
|
+
|
|
65
|
+
# Usage counters
|
|
66
|
+
daily_usage_count: int = 0
|
|
67
|
+
monthly_usage_count: int = 0
|
|
68
|
+
total_usage_count: int = 0
|
|
69
|
+
|
|
70
|
+
# Concurrent usage
|
|
71
|
+
concurrent_usage: int = 0
|
|
72
|
+
max_concurrent: Optional[int] = None
|
|
73
|
+
|
|
74
|
+
# Rotation
|
|
75
|
+
weight: int = 1
|
|
76
|
+
priority: int = 0
|
|
77
|
+
|
|
78
|
+
# Metadata
|
|
79
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
80
|
+
tags: list[str] = Field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
# Timestamps
|
|
83
|
+
last_used_at: Optional[datetime] = None
|
|
84
|
+
last_reset_daily: date = Field(default_factory=date.today)
|
|
85
|
+
last_reset_monthly: date = Field(default_factory=date.today)
|
|
86
|
+
expires_at: Optional[datetime] = None
|
|
87
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
88
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
89
|
+
|
|
90
|
+
# -- Computed properties ------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_expired(self) -> bool:
|
|
94
|
+
"""Return ``True`` if the key has passed its expiration time."""
|
|
95
|
+
if self.expires_at is None:
|
|
96
|
+
return False
|
|
97
|
+
now = datetime.now(timezone.utc)
|
|
98
|
+
# Handle naive datetimes stored without tz info
|
|
99
|
+
expires = self.expires_at
|
|
100
|
+
if expires.tzinfo is None:
|
|
101
|
+
expires = expires.replace(tzinfo=timezone.utc)
|
|
102
|
+
return now >= expires
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_rate_limited(self) -> bool:
|
|
106
|
+
"""Return ``True`` if any configured rate limit has been reached."""
|
|
107
|
+
if self.daily_limit is not None and self.daily_usage_count >= self.daily_limit:
|
|
108
|
+
return True
|
|
109
|
+
if self.monthly_limit is not None and self.monthly_usage_count >= self.monthly_limit:
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def has_capacity(self) -> bool:
|
|
115
|
+
"""Return ``True`` if the key can accept another request right now.
|
|
116
|
+
|
|
117
|
+
A key has capacity when it is **active**, **not expired**,
|
|
118
|
+
**not rate-limited**, and below its concurrent-usage ceiling.
|
|
119
|
+
"""
|
|
120
|
+
if self.is_expired:
|
|
121
|
+
return False
|
|
122
|
+
if self.is_rate_limited:
|
|
123
|
+
return False
|
|
124
|
+
if self.status != KeyStatus.ACTIVE:
|
|
125
|
+
return False
|
|
126
|
+
if self.max_concurrent is not None and self.concurrent_usage >= self.max_concurrent:
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def needs_daily_reset(self) -> bool:
|
|
132
|
+
"""Return ``True`` if daily counters are stale (belong to a previous day)."""
|
|
133
|
+
return self.last_reset_daily < date.today()
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def needs_monthly_reset(self) -> bool:
|
|
137
|
+
"""Return ``True`` if monthly counters are stale (belong to a previous month)."""
|
|
138
|
+
today = date.today()
|
|
139
|
+
return (
|
|
140
|
+
self.last_reset_monthly.year < today.year
|
|
141
|
+
or self.last_reset_monthly.month < today.month
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Request / response helpers
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class KeyCreateRequest(BaseModel):
|
|
151
|
+
"""Input schema for creating a new API key.
|
|
152
|
+
|
|
153
|
+
Only ``provider`` and ``key_value`` are mandatory; all other fields
|
|
154
|
+
fall back to sensible defaults on the resulting :class:`APIKey`.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
158
|
+
|
|
159
|
+
provider: Provider
|
|
160
|
+
key_value: str
|
|
161
|
+
alias: Optional[str] = None
|
|
162
|
+
daily_limit: Optional[int] = None
|
|
163
|
+
monthly_limit: Optional[int] = None
|
|
164
|
+
max_concurrent: Optional[int] = None
|
|
165
|
+
environment: Environment = Environment.PRODUCTION
|
|
166
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
167
|
+
tags: list[str] = Field(default_factory=list)
|
|
168
|
+
expires_at: Optional[datetime] = None
|
|
169
|
+
weight: int = 1
|
|
170
|
+
priority: int = 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class KeyUpdateRequest(BaseModel):
|
|
174
|
+
"""Input schema for partially updating an existing API key.
|
|
175
|
+
|
|
176
|
+
Every field is optional — only the fields that are set (not ``None``)
|
|
177
|
+
should be applied to the target :class:`APIKey`.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
181
|
+
|
|
182
|
+
alias: Optional[str] = None
|
|
183
|
+
status: Optional[KeyStatus] = None
|
|
184
|
+
daily_limit: Optional[int] = None
|
|
185
|
+
monthly_limit: Optional[int] = None
|
|
186
|
+
max_concurrent: Optional[int] = None
|
|
187
|
+
environment: Optional[Environment] = None
|
|
188
|
+
metadata: Optional[dict[str, Any]] = None
|
|
189
|
+
tags: Optional[list[str]] = None
|
|
190
|
+
expires_at: Optional[datetime] = None
|
|
191
|
+
weight: Optional[int] = None
|
|
192
|
+
priority: Optional[int] = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Filtering
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class KeyFilter(BaseModel):
|
|
201
|
+
"""Declarative filter for querying stored API keys.
|
|
202
|
+
|
|
203
|
+
Only non-``None`` fields participate in matching. Use the
|
|
204
|
+
:meth:`matches` helper to test a candidate :class:`APIKey`.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
208
|
+
|
|
209
|
+
provider: Optional[Provider] = None
|
|
210
|
+
status: Optional[KeyStatus] = None
|
|
211
|
+
environment: Optional[Environment] = None
|
|
212
|
+
tags: Optional[list[str]] = None
|
|
213
|
+
metadata_filter: Optional[dict[str, Any]] = None
|
|
214
|
+
has_capacity: Optional[bool] = None
|
|
215
|
+
alias_contains: Optional[str] = None
|
|
216
|
+
|
|
217
|
+
def matches(self, key: APIKey) -> bool:
|
|
218
|
+
"""Return ``True`` if *key* satisfies every set filter criterion.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
key: The API key to evaluate.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
``True`` when all non-``None`` filter fields match the key.
|
|
225
|
+
"""
|
|
226
|
+
if self.provider is not None and key.provider != self.provider:
|
|
227
|
+
return False
|
|
228
|
+
if self.status is not None and key.status != self.status:
|
|
229
|
+
return False
|
|
230
|
+
if self.environment is not None and key.environment != self.environment:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# Tag filter: at least one of the requested tags must be present
|
|
234
|
+
if self.tags is not None:
|
|
235
|
+
if not any(tag in key.tags for tag in self.tags):
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
# Metadata filter: all key/value pairs must match
|
|
239
|
+
if self.metadata_filter is not None:
|
|
240
|
+
for meta_key, meta_value in self.metadata_filter.items():
|
|
241
|
+
if key.metadata.get(meta_key) != meta_value:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
# Capacity filter
|
|
245
|
+
if self.has_capacity is not None:
|
|
246
|
+
if key.has_capacity != self.has_capacity:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
# Alias substring search (case-insensitive)
|
|
250
|
+
if self.alias_contains is not None:
|
|
251
|
+
if key.alias is None:
|
|
252
|
+
return False
|
|
253
|
+
if self.alias_contains.lower() not in key.alias.lower():
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Usage statistics
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class UsageStats(BaseModel):
|
|
265
|
+
"""Aggregated usage statistics snapshot for a single API key."""
|
|
266
|
+
|
|
267
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
268
|
+
|
|
269
|
+
key_id: str
|
|
270
|
+
provider: Provider
|
|
271
|
+
alias: Optional[str] = None
|
|
272
|
+
|
|
273
|
+
daily_usage_count: int = 0
|
|
274
|
+
monthly_usage_count: int = 0
|
|
275
|
+
total_usage_count: int = 0
|
|
276
|
+
concurrent_usage: int = 0
|
|
277
|
+
|
|
278
|
+
daily_limit: Optional[int] = None
|
|
279
|
+
monthly_limit: Optional[int] = None
|
|
280
|
+
max_concurrent: Optional[int] = None
|
|
281
|
+
|
|
282
|
+
daily_remaining: Optional[int] = None
|
|
283
|
+
monthly_remaining: Optional[int] = None
|
|
284
|
+
|
|
285
|
+
last_used_at: Optional[datetime] = None
|
|
286
|
+
status: KeyStatus = KeyStatus.ACTIVE
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Bulk operations
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class BulkOperationResult(BaseModel):
|
|
295
|
+
"""Summary returned after a bulk create / update / delete operation."""
|
|
296
|
+
|
|
297
|
+
total: int = 0
|
|
298
|
+
successful: int = 0
|
|
299
|
+
failed: int = 0
|
|
300
|
+
errors: list[str] = Field(default_factory=list)
|
|
301
|
+
created_ids: list[str] = Field(default_factory=list)
|
|
File without changes
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Rate limiting enforcement for API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .enums import KeyStatus
|
|
9
|
+
from .exceptions import RateLimitExceededError
|
|
10
|
+
from .models import APIKey
|
|
11
|
+
from .storage.base import StorageBackend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimiter:
|
|
15
|
+
"""Enforces daily and monthly rate limits on API keys.
|
|
16
|
+
|
|
17
|
+
Handles:
|
|
18
|
+
- Checking if a key has capacity before use
|
|
19
|
+
- Auto-resetting daily/monthly counters when the period rolls over
|
|
20
|
+
- Marking keys as RATE_LIMITED when limits are hit
|
|
21
|
+
- Recovering keys when counters reset
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, storage: StorageBackend, auto_reset: bool = True) -> None:
|
|
25
|
+
"""Initialize the rate limiter.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
storage: The storage backend for persisting counter changes.
|
|
29
|
+
auto_reset: If True, auto-reset expired counters on access.
|
|
30
|
+
"""
|
|
31
|
+
self._storage = storage
|
|
32
|
+
self._auto_reset = auto_reset
|
|
33
|
+
|
|
34
|
+
async def check_and_update(self, key: APIKey) -> APIKey:
|
|
35
|
+
"""Check if a key needs counter resets and apply them.
|
|
36
|
+
|
|
37
|
+
This is called before using a key to ensure counters are current.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
key: The API key to check.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The updated key (with reset counters if applicable).
|
|
44
|
+
"""
|
|
45
|
+
if not self._auto_reset:
|
|
46
|
+
return key
|
|
47
|
+
|
|
48
|
+
today = date.today()
|
|
49
|
+
needs_refresh = False
|
|
50
|
+
|
|
51
|
+
# Check daily reset
|
|
52
|
+
if key.needs_daily_reset:
|
|
53
|
+
await self._storage.reset_daily_counts(today)
|
|
54
|
+
needs_refresh = True
|
|
55
|
+
|
|
56
|
+
# Check monthly reset
|
|
57
|
+
if key.needs_monthly_reset:
|
|
58
|
+
await self._storage.reset_monthly_counts(today)
|
|
59
|
+
needs_refresh = True
|
|
60
|
+
|
|
61
|
+
if needs_refresh:
|
|
62
|
+
key = await self._storage.get_key(key.id)
|
|
63
|
+
|
|
64
|
+
return key
|
|
65
|
+
|
|
66
|
+
async def check_capacity(self, key: APIKey) -> bool:
|
|
67
|
+
"""Check if a key has remaining capacity.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
key: The key to check.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if the key can accept more requests.
|
|
74
|
+
"""
|
|
75
|
+
key = await self.check_and_update(key)
|
|
76
|
+
|
|
77
|
+
# Check daily limit
|
|
78
|
+
if key.daily_limit is not None and key.daily_usage_count >= key.daily_limit:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# Check monthly limit
|
|
82
|
+
if key.monthly_limit is not None and key.monthly_usage_count >= key.monthly_limit:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# Check concurrent limit
|
|
86
|
+
if key.max_concurrent is not None and key.concurrent_usage >= key.max_concurrent:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
async def enforce_limits(self, key: APIKey) -> APIKey:
|
|
92
|
+
"""Enforce rate limits, raising an exception if limits are exceeded.
|
|
93
|
+
|
|
94
|
+
Call this before each API request to ensure the key is within limits.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
key: The key to check.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The validated key.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
RateLimitExceededError: If daily or monthly limit is exceeded.
|
|
104
|
+
"""
|
|
105
|
+
key = await self.check_and_update(key)
|
|
106
|
+
|
|
107
|
+
# Check daily limit
|
|
108
|
+
if key.daily_limit is not None and key.daily_usage_count >= key.daily_limit:
|
|
109
|
+
# Mark as rate limited
|
|
110
|
+
from .models import KeyUpdateRequest
|
|
111
|
+
await self._storage.update_key(
|
|
112
|
+
key.id,
|
|
113
|
+
KeyUpdateRequest(status=KeyStatus.RATE_LIMITED),
|
|
114
|
+
)
|
|
115
|
+
raise RateLimitExceededError(
|
|
116
|
+
key_id=key.id,
|
|
117
|
+
limit_type="daily",
|
|
118
|
+
limit=key.daily_limit,
|
|
119
|
+
current=key.daily_usage_count,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Check monthly limit
|
|
123
|
+
if key.monthly_limit is not None and key.monthly_usage_count >= key.monthly_limit:
|
|
124
|
+
from .models import KeyUpdateRequest
|
|
125
|
+
await self._storage.update_key(
|
|
126
|
+
key.id,
|
|
127
|
+
KeyUpdateRequest(status=KeyStatus.RATE_LIMITED),
|
|
128
|
+
)
|
|
129
|
+
raise RateLimitExceededError(
|
|
130
|
+
key_id=key.id,
|
|
131
|
+
limit_type="monthly",
|
|
132
|
+
limit=key.monthly_limit,
|
|
133
|
+
current=key.monthly_usage_count,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return key
|
|
137
|
+
|
|
138
|
+
async def record_usage(self, key_id: str) -> None:
|
|
139
|
+
"""Record a single usage event for a key.
|
|
140
|
+
|
|
141
|
+
Increments daily, monthly, and total counters by 1.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
key_id: The key to record usage for.
|
|
145
|
+
"""
|
|
146
|
+
await self._storage.increment_usage(key_id, daily=1, monthly=1, total=1)
|
|
147
|
+
|
|
148
|
+
async def get_remaining(self, key: APIKey) -> dict[str, Optional[int]]:
|
|
149
|
+
"""Get remaining capacity for a key.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
key: The key to check.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Dict with 'daily_remaining', 'monthly_remaining' values.
|
|
156
|
+
None means unlimited.
|
|
157
|
+
"""
|
|
158
|
+
key = await self.check_and_update(key)
|
|
159
|
+
|
|
160
|
+
daily_remaining = None
|
|
161
|
+
if key.daily_limit is not None:
|
|
162
|
+
daily_remaining = max(0, key.daily_limit - key.daily_usage_count)
|
|
163
|
+
|
|
164
|
+
monthly_remaining = None
|
|
165
|
+
if key.monthly_limit is not None:
|
|
166
|
+
monthly_remaining = max(0, key.monthly_limit - key.monthly_usage_count)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"daily_remaining": daily_remaining,
|
|
170
|
+
"monthly_remaining": monthly_remaining,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async def reset_daily(self) -> int:
|
|
174
|
+
"""Manually trigger a daily reset for all keys.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Number of keys reset.
|
|
178
|
+
"""
|
|
179
|
+
return await self._storage.reset_daily_counts(date.today())
|
|
180
|
+
|
|
181
|
+
async def reset_monthly(self) -> int:
|
|
182
|
+
"""Manually trigger a monthly reset for all keys.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Number of keys reset.
|
|
186
|
+
"""
|
|
187
|
+
return await self._storage.reset_monthly_counts(date.today())
|