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.
@@ -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())