zae-limiter 0.1.0__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.
- zae_limiter/__init__.py +130 -0
- zae_limiter/aggregator/__init__.py +11 -0
- zae_limiter/aggregator/handler.py +54 -0
- zae_limiter/aggregator/processor.py +270 -0
- zae_limiter/bucket.py +291 -0
- zae_limiter/cli.py +608 -0
- zae_limiter/exceptions.py +214 -0
- zae_limiter/infra/__init__.py +10 -0
- zae_limiter/infra/cfn_template.yaml +255 -0
- zae_limiter/infra/lambda_builder.py +85 -0
- zae_limiter/infra/stack_manager.py +536 -0
- zae_limiter/lease.py +196 -0
- zae_limiter/limiter.py +925 -0
- zae_limiter/migrations/__init__.py +114 -0
- zae_limiter/migrations/v1_0_0.py +55 -0
- zae_limiter/models.py +302 -0
- zae_limiter/repository.py +656 -0
- zae_limiter/schema.py +163 -0
- zae_limiter/version.py +214 -0
- zae_limiter-0.1.0.dist-info/METADATA +470 -0
- zae_limiter-0.1.0.dist-info/RECORD +24 -0
- zae_limiter-0.1.0.dist-info/WHEEL +4 -0
- zae_limiter-0.1.0.dist-info/entry_points.txt +2 -0
- zae_limiter-0.1.0.dist-info/licenses/LICENSE +21 -0
zae_limiter/schema.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""DynamoDB schema definitions and key builders."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# Table and index names
|
|
6
|
+
DEFAULT_TABLE_NAME = "rate_limits"
|
|
7
|
+
GSI1_NAME = "GSI1" # For parent -> children lookups
|
|
8
|
+
GSI2_NAME = "GSI2" # For resource aggregation
|
|
9
|
+
|
|
10
|
+
# Key prefixes
|
|
11
|
+
ENTITY_PREFIX = "ENTITY#"
|
|
12
|
+
PARENT_PREFIX = "PARENT#"
|
|
13
|
+
CHILD_PREFIX = "CHILD#"
|
|
14
|
+
RESOURCE_PREFIX = "RESOURCE#"
|
|
15
|
+
SYSTEM_PREFIX = "SYSTEM#"
|
|
16
|
+
|
|
17
|
+
# Sort key prefixes
|
|
18
|
+
SK_META = "#META"
|
|
19
|
+
SK_BUCKET = "#BUCKET#"
|
|
20
|
+
SK_LIMIT = "#LIMIT#"
|
|
21
|
+
SK_RESOURCE = "#RESOURCE#"
|
|
22
|
+
SK_USAGE = "#USAGE#"
|
|
23
|
+
SK_VERSION = "#VERSION"
|
|
24
|
+
|
|
25
|
+
# Special resource for default limits
|
|
26
|
+
DEFAULT_RESOURCE = "_default_"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pk_entity(entity_id: str) -> str:
|
|
30
|
+
"""Build partition key for an entity."""
|
|
31
|
+
return f"{ENTITY_PREFIX}{entity_id}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pk_system() -> str:
|
|
35
|
+
"""Build partition key for system records (e.g., version)."""
|
|
36
|
+
return SYSTEM_PREFIX
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sk_version() -> str:
|
|
40
|
+
"""Build sort key for version record."""
|
|
41
|
+
return SK_VERSION
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sk_meta() -> str:
|
|
45
|
+
"""Build sort key for entity metadata."""
|
|
46
|
+
return SK_META
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sk_bucket(resource: str, limit_name: str) -> str:
|
|
50
|
+
"""Build sort key for a bucket."""
|
|
51
|
+
return f"{SK_BUCKET}{resource}#{limit_name}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sk_limit(resource: str, limit_name: str) -> str:
|
|
55
|
+
"""Build sort key for a limit config."""
|
|
56
|
+
return f"{SK_LIMIT}{resource}#{limit_name}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sk_limit_prefix(resource: str) -> str:
|
|
60
|
+
"""Build sort key prefix for querying limits by resource."""
|
|
61
|
+
return f"{SK_LIMIT}{resource}#"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sk_resource(resource: str) -> str:
|
|
65
|
+
"""Build sort key for resource access tracking."""
|
|
66
|
+
return f"{SK_RESOURCE}{resource}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sk_usage(resource: str, window_key: str) -> str:
|
|
70
|
+
"""Build sort key for usage snapshot."""
|
|
71
|
+
return f"{SK_USAGE}{resource}#{window_key}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def gsi1_pk_parent(parent_id: str) -> str:
|
|
75
|
+
"""Build GSI1 partition key for parent lookup."""
|
|
76
|
+
return f"{PARENT_PREFIX}{parent_id}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gsi1_sk_child(entity_id: str) -> str:
|
|
80
|
+
"""Build GSI1 sort key for child entry."""
|
|
81
|
+
return f"{CHILD_PREFIX}{entity_id}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def gsi2_pk_resource(resource: str) -> str:
|
|
85
|
+
"""Build GSI2 partition key for resource aggregation."""
|
|
86
|
+
return f"{RESOURCE_PREFIX}{resource}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def gsi2_sk_bucket(entity_id: str, limit_name: str) -> str:
|
|
90
|
+
"""Build GSI2 sort key for bucket entry."""
|
|
91
|
+
return f"BUCKET#{entity_id}#{limit_name}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def gsi2_sk_access(entity_id: str) -> str:
|
|
95
|
+
"""Build GSI2 sort key for access tracking entry."""
|
|
96
|
+
return f"ACCESS#{entity_id}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def gsi2_sk_usage(window_key: str, entity_id: str) -> str:
|
|
100
|
+
"""Build GSI2 sort key for usage snapshot entry."""
|
|
101
|
+
return f"USAGE#{window_key}#{entity_id}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_bucket_sk(sk: str) -> tuple[str, str]:
|
|
105
|
+
"""Parse resource and limit_name from bucket sort key."""
|
|
106
|
+
# SK format: #BUCKET#{resource}#{limit_name}
|
|
107
|
+
if not sk.startswith(SK_BUCKET):
|
|
108
|
+
raise ValueError(f"Invalid bucket SK: {sk}")
|
|
109
|
+
parts = sk[len(SK_BUCKET) :].split("#", 1)
|
|
110
|
+
if len(parts) != 2:
|
|
111
|
+
raise ValueError(f"Invalid bucket SK format: {sk}")
|
|
112
|
+
return parts[0], parts[1]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_table_definition(table_name: str) -> dict[str, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Get the DynamoDB table definition for CreateTable.
|
|
118
|
+
|
|
119
|
+
Returns a dictionary suitable for boto3 create_table().
|
|
120
|
+
"""
|
|
121
|
+
return {
|
|
122
|
+
"TableName": table_name,
|
|
123
|
+
"BillingMode": "PAY_PER_REQUEST",
|
|
124
|
+
"AttributeDefinitions": [
|
|
125
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
126
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
127
|
+
{"AttributeName": "GSI1PK", "AttributeType": "S"},
|
|
128
|
+
{"AttributeName": "GSI1SK", "AttributeType": "S"},
|
|
129
|
+
{"AttributeName": "GSI2PK", "AttributeType": "S"},
|
|
130
|
+
{"AttributeName": "GSI2SK", "AttributeType": "S"},
|
|
131
|
+
],
|
|
132
|
+
"KeySchema": [
|
|
133
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
134
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
135
|
+
],
|
|
136
|
+
"GlobalSecondaryIndexes": [
|
|
137
|
+
{
|
|
138
|
+
"IndexName": GSI1_NAME,
|
|
139
|
+
"KeySchema": [
|
|
140
|
+
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
|
|
141
|
+
{"AttributeName": "GSI1SK", "KeyType": "RANGE"},
|
|
142
|
+
],
|
|
143
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"IndexName": GSI2_NAME,
|
|
147
|
+
"KeySchema": [
|
|
148
|
+
{"AttributeName": "GSI2PK", "KeyType": "HASH"},
|
|
149
|
+
{"AttributeName": "GSI2SK", "KeyType": "RANGE"},
|
|
150
|
+
],
|
|
151
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
"StreamSpecification": {
|
|
155
|
+
"StreamEnabled": True,
|
|
156
|
+
"StreamViewType": "NEW_AND_OLD_IMAGES",
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def calculate_ttl(now_ms: int, ttl_seconds: int = 86400) -> int:
|
|
162
|
+
"""Calculate TTL timestamp (epoch seconds)."""
|
|
163
|
+
return (now_ms // 1000) + ttl_seconds
|
zae_limiter/version.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Version tracking and compatibility checking for zae-limiter infrastructure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Current schema version - increment when schema changes
|
|
9
|
+
CURRENT_SCHEMA_VERSION = "1.0.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, order=False)
|
|
13
|
+
class ParsedVersion:
|
|
14
|
+
"""Parsed semantic version components."""
|
|
15
|
+
|
|
16
|
+
major: int
|
|
17
|
+
minor: int
|
|
18
|
+
patch: int
|
|
19
|
+
prerelease: str | None = None
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
23
|
+
if self.prerelease:
|
|
24
|
+
return f"{base}-{self.prerelease}"
|
|
25
|
+
return base
|
|
26
|
+
|
|
27
|
+
def __lt__(self, other: ParsedVersion) -> bool:
|
|
28
|
+
# Compare major.minor.patch first
|
|
29
|
+
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
|
30
|
+
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
|
31
|
+
# Prerelease versions are less than release versions
|
|
32
|
+
if self.prerelease and not other.prerelease:
|
|
33
|
+
return True
|
|
34
|
+
if not self.prerelease and other.prerelease:
|
|
35
|
+
return False
|
|
36
|
+
# Both have prerelease, compare lexically
|
|
37
|
+
return (self.prerelease or "") < (other.prerelease or "")
|
|
38
|
+
|
|
39
|
+
def __le__(self, other: ParsedVersion) -> bool:
|
|
40
|
+
return self == other or self < other
|
|
41
|
+
|
|
42
|
+
def __gt__(self, other: ParsedVersion) -> bool:
|
|
43
|
+
return not self <= other
|
|
44
|
+
|
|
45
|
+
def __ge__(self, other: ParsedVersion) -> bool:
|
|
46
|
+
return not self < other
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_version(version_str: str) -> ParsedVersion:
|
|
50
|
+
"""
|
|
51
|
+
Parse a semantic version string.
|
|
52
|
+
|
|
53
|
+
Handles formats like:
|
|
54
|
+
- "1.2.3"
|
|
55
|
+
- "1.2.3-dev"
|
|
56
|
+
- "1.2.3.dev123+gabcdef"
|
|
57
|
+
- "0.1.0"
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
version_str: Version string to parse
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
ParsedVersion tuple
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If version string is invalid
|
|
67
|
+
"""
|
|
68
|
+
# Remove leading 'v' if present
|
|
69
|
+
if version_str.startswith("v"):
|
|
70
|
+
version_str = version_str[1:]
|
|
71
|
+
|
|
72
|
+
# Handle PEP 440 dev versions (e.g., "0.1.0.dev123+gabcdef")
|
|
73
|
+
# Convert to semver-like format
|
|
74
|
+
version_str = re.sub(r"\.dev\d+.*$", "-dev", version_str)
|
|
75
|
+
|
|
76
|
+
# Match standard semver with optional prerelease
|
|
77
|
+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", version_str)
|
|
78
|
+
if not match:
|
|
79
|
+
raise ValueError(f"Invalid version string: {version_str}")
|
|
80
|
+
|
|
81
|
+
return ParsedVersion(
|
|
82
|
+
major=int(match.group(1)),
|
|
83
|
+
minor=int(match.group(2)),
|
|
84
|
+
patch=int(match.group(3)),
|
|
85
|
+
prerelease=match.group(4),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class InfrastructureVersion:
|
|
91
|
+
"""Version information for deployed infrastructure."""
|
|
92
|
+
|
|
93
|
+
schema_version: str
|
|
94
|
+
lambda_version: str | None
|
|
95
|
+
template_version: str | None
|
|
96
|
+
client_min_version: str
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_record(cls, record: dict[str, str | None]) -> InfrastructureVersion:
|
|
100
|
+
"""Create from a version record dictionary."""
|
|
101
|
+
return cls(
|
|
102
|
+
schema_version=record.get("schema_version") or "1.0.0",
|
|
103
|
+
lambda_version=record.get("lambda_version"),
|
|
104
|
+
template_version=record.get("template_version"),
|
|
105
|
+
client_min_version=record.get("client_min_version") or "0.0.0",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class CompatibilityResult:
|
|
111
|
+
"""Result of a version compatibility check."""
|
|
112
|
+
|
|
113
|
+
is_compatible: bool
|
|
114
|
+
requires_schema_migration: bool = False
|
|
115
|
+
requires_lambda_update: bool = False
|
|
116
|
+
requires_template_update: bool = False
|
|
117
|
+
message: str = ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_compatibility(
|
|
121
|
+
client_version: str,
|
|
122
|
+
infra_version: InfrastructureVersion,
|
|
123
|
+
) -> CompatibilityResult:
|
|
124
|
+
"""
|
|
125
|
+
Check compatibility between client and infrastructure versions.
|
|
126
|
+
|
|
127
|
+
Rules:
|
|
128
|
+
- Major version mismatch in schema: Always incompatible (requires migration)
|
|
129
|
+
- Client version < client_min_version: Incompatible (client too old)
|
|
130
|
+
- Lambda version < client version: Lambda update available
|
|
131
|
+
- Patch version differences: Always compatible
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
client_version: The client library version (e.g., "1.2.3")
|
|
135
|
+
infra_version: The infrastructure version information
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
CompatibilityResult with compatibility status and details
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
client = parse_version(client_version)
|
|
142
|
+
except ValueError:
|
|
143
|
+
return CompatibilityResult(
|
|
144
|
+
is_compatible=False,
|
|
145
|
+
message=f"Invalid client version: {client_version}",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
schema = parse_version(infra_version.schema_version)
|
|
150
|
+
except ValueError:
|
|
151
|
+
return CompatibilityResult(
|
|
152
|
+
is_compatible=False,
|
|
153
|
+
message=f"Invalid schema version: {infra_version.schema_version}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
min_version = parse_version(infra_version.client_min_version)
|
|
158
|
+
except ValueError:
|
|
159
|
+
min_version = ParsedVersion(0, 0, 0)
|
|
160
|
+
|
|
161
|
+
# Check if client meets minimum version requirement
|
|
162
|
+
if client < min_version:
|
|
163
|
+
return CompatibilityResult(
|
|
164
|
+
is_compatible=False,
|
|
165
|
+
message=(
|
|
166
|
+
f"Client version {client_version} is below minimum required "
|
|
167
|
+
f"version {infra_version.client_min_version}. Please upgrade."
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Check schema compatibility (major version must match)
|
|
172
|
+
if client.major != schema.major:
|
|
173
|
+
return CompatibilityResult(
|
|
174
|
+
is_compatible=False,
|
|
175
|
+
requires_schema_migration=True,
|
|
176
|
+
message=(
|
|
177
|
+
f"Schema version mismatch: client major version {client.major} "
|
|
178
|
+
f"!= schema major version {schema.major}. "
|
|
179
|
+
"Schema migration required."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Check if Lambda needs update
|
|
184
|
+
requires_lambda_update = False
|
|
185
|
+
if infra_version.lambda_version:
|
|
186
|
+
try:
|
|
187
|
+
lambda_v = parse_version(infra_version.lambda_version)
|
|
188
|
+
# Lambda update needed if client is newer (ignoring prerelease for comparison)
|
|
189
|
+
client_release = ParsedVersion(client.major, client.minor, client.patch)
|
|
190
|
+
lambda_release = ParsedVersion(lambda_v.major, lambda_v.minor, lambda_v.patch)
|
|
191
|
+
requires_lambda_update = lambda_release < client_release
|
|
192
|
+
except ValueError:
|
|
193
|
+
# If Lambda version is invalid, suggest update
|
|
194
|
+
requires_lambda_update = True
|
|
195
|
+
|
|
196
|
+
if requires_lambda_update:
|
|
197
|
+
return CompatibilityResult(
|
|
198
|
+
is_compatible=True, # Can still work, but update available
|
|
199
|
+
requires_lambda_update=True,
|
|
200
|
+
message=(
|
|
201
|
+
f"Lambda update available: {infra_version.lambda_version} -> {client_version}"
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Fully compatible
|
|
206
|
+
return CompatibilityResult(
|
|
207
|
+
is_compatible=True,
|
|
208
|
+
message="Client and infrastructure versions are compatible.",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_schema_version() -> str:
|
|
213
|
+
"""Get the current schema version."""
|
|
214
|
+
return CURRENT_SCHEMA_VERSION
|