tyler-ag-tapper 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.
- tapper/__init__.py +66 -0
- tapper/cli.py +109 -0
- tapper/exceptions.py +35 -0
- tapper/gateway.py +294 -0
- tapper/load_balancer.py +148 -0
- tapper/models.py +53 -0
- tapper/registry/__init__.py +18 -0
- tapper/registry/backends/__init__.py +12 -0
- tapper/registry/backends/base.py +43 -0
- tapper/registry/backends/memory.py +93 -0
- tapper/registry/backends/redis.py +163 -0
- tapper/registry/client.py +190 -0
- tapper/registry/server.py +106 -0
- tapper/service.py +156 -0
- tyler_ag_tapper-0.1.0.dist-info/METADATA +194 -0
- tyler_ag_tapper-0.1.0.dist-info/RECORD +18 -0
- tyler_ag_tapper-0.1.0.dist-info/WHEEL +4 -0
- tyler_ag_tapper-0.1.0.dist-info/entry_points.txt +2 -0
tapper/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tapper - A microservices framework for FastAPI.
|
|
2
|
+
|
|
3
|
+
Tapper provides service discovery and API gateway routing for FastAPI applications.
|
|
4
|
+
|
|
5
|
+
Quick Start:
|
|
6
|
+
# Register a service
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from tapper import Service
|
|
9
|
+
|
|
10
|
+
@Service(name="user-service", version="1.0.0")
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
|
|
13
|
+
# Create a gateway
|
|
14
|
+
from tapper import TapperGateway
|
|
15
|
+
|
|
16
|
+
gateway = TapperGateway(registry_url="http://localhost:8001")
|
|
17
|
+
|
|
18
|
+
# Run with uvicorn
|
|
19
|
+
# uvicorn gateway:gateway --port 8000
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
See the README for full usage examples.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from tapper.exceptions import (
|
|
26
|
+
LoadBalancerError,
|
|
27
|
+
NoHealthyInstanceError,
|
|
28
|
+
RegistryError,
|
|
29
|
+
ServiceNotFoundError,
|
|
30
|
+
TapperException,
|
|
31
|
+
)
|
|
32
|
+
from tapper.gateway import TapperGateway
|
|
33
|
+
from tapper.load_balancer import (
|
|
34
|
+
LeastConnectionsBalancer,
|
|
35
|
+
LoadBalancer,
|
|
36
|
+
RandomBalancer,
|
|
37
|
+
RoundRobinBalancer,
|
|
38
|
+
)
|
|
39
|
+
from tapper.models import Route, ServiceInfo, ServiceInstance
|
|
40
|
+
from tapper.registry import RegistryBackend
|
|
41
|
+
from tapper.service import Service
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Core
|
|
47
|
+
"Service",
|
|
48
|
+
"TapperGateway",
|
|
49
|
+
# Load Balancing
|
|
50
|
+
"LoadBalancer",
|
|
51
|
+
"RoundRobinBalancer",
|
|
52
|
+
"RandomBalancer",
|
|
53
|
+
"LeastConnectionsBalancer",
|
|
54
|
+
# Models
|
|
55
|
+
"ServiceInfo",
|
|
56
|
+
"ServiceInstance",
|
|
57
|
+
"Route",
|
|
58
|
+
# Registry
|
|
59
|
+
"RegistryBackend",
|
|
60
|
+
# Exceptions
|
|
61
|
+
"TapperException",
|
|
62
|
+
"RegistryError",
|
|
63
|
+
"ServiceNotFoundError",
|
|
64
|
+
"NoHealthyInstanceError",
|
|
65
|
+
"LoadBalancerError",
|
|
66
|
+
]
|
tapper/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Command-line interface for Tapper."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import uvicorn
|
|
5
|
+
|
|
6
|
+
from tapper.registry.backends import InMemoryBackend, RegistryBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_backend(backend_type: str, redis_url: str | None) -> RegistryBackend:
|
|
10
|
+
"""Create a registry backend based on the specified type."""
|
|
11
|
+
if backend_type == "memory":
|
|
12
|
+
return InMemoryBackend()
|
|
13
|
+
elif backend_type == "redis":
|
|
14
|
+
try:
|
|
15
|
+
from tapper.registry.backends.redis import RedisBackend
|
|
16
|
+
except ImportError:
|
|
17
|
+
raise click.ClickException(
|
|
18
|
+
"Redis backend requires the 'redis' package. "
|
|
19
|
+
"Install with: pip install tapper[redis]"
|
|
20
|
+
)
|
|
21
|
+
url = redis_url or "redis://localhost:6379"
|
|
22
|
+
return RedisBackend(redis_url=url)
|
|
23
|
+
else:
|
|
24
|
+
raise click.ClickException(f"Unknown backend type: {backend_type}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
@click.version_option(version="0.1.0")
|
|
29
|
+
def main() -> None:
|
|
30
|
+
"""Tapper - A microservices framework for FastAPI."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@main.command()
|
|
35
|
+
@click.option(
|
|
36
|
+
"--host",
|
|
37
|
+
default="0.0.0.0",
|
|
38
|
+
help="Host to bind the server to",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--port",
|
|
42
|
+
default=8001,
|
|
43
|
+
type=int,
|
|
44
|
+
help="Port to bind the server to",
|
|
45
|
+
)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--backend",
|
|
48
|
+
default="memory",
|
|
49
|
+
type=click.Choice(["memory", "redis"]),
|
|
50
|
+
help="Storage backend for the registry",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--redis-url",
|
|
54
|
+
default=None,
|
|
55
|
+
help="Redis URL (only used with redis backend)",
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--reload",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
default=False,
|
|
61
|
+
help="Enable auto-reload for development",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--cleanup-interval",
|
|
65
|
+
default=30,
|
|
66
|
+
type=int,
|
|
67
|
+
help="Seconds between stale instance cleanup",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--stale-threshold",
|
|
71
|
+
default=60,
|
|
72
|
+
type=int,
|
|
73
|
+
help="Seconds after which an instance is considered stale",
|
|
74
|
+
)
|
|
75
|
+
def registry(
|
|
76
|
+
host: str,
|
|
77
|
+
port: int,
|
|
78
|
+
backend: str,
|
|
79
|
+
redis_url: str | None,
|
|
80
|
+
reload: bool,
|
|
81
|
+
cleanup_interval: int,
|
|
82
|
+
stale_threshold: int,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Run the Tapper service registry server."""
|
|
85
|
+
click.echo(f"Starting Tapper registry on {host}:{port}")
|
|
86
|
+
click.echo(f"Backend: {backend}")
|
|
87
|
+
|
|
88
|
+
if reload:
|
|
89
|
+
uvicorn.run(
|
|
90
|
+
"tapper.registry.server:create_registry_app",
|
|
91
|
+
factory=True,
|
|
92
|
+
host=host,
|
|
93
|
+
port=port,
|
|
94
|
+
reload=reload,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
from tapper.registry.server import create_registry_app
|
|
98
|
+
|
|
99
|
+
backend_instance = get_backend(backend, redis_url)
|
|
100
|
+
app = create_registry_app(
|
|
101
|
+
backend=backend_instance,
|
|
102
|
+
cleanup_interval=cleanup_interval,
|
|
103
|
+
stale_threshold=stale_threshold,
|
|
104
|
+
)
|
|
105
|
+
uvicorn.run(app, host=host, port=port)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
tapper/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Custom exceptions for the Tapper framework."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TapperException(Exception):
|
|
5
|
+
"""Base exception for all Tapper-related errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RegistryError(TapperException):
|
|
11
|
+
"""Raised when there's an error communicating with the registry."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ServiceNotFoundError(TapperException):
|
|
17
|
+
"""Raised when a requested service is not found in the registry."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, service_name: str):
|
|
20
|
+
self.service_name = service_name
|
|
21
|
+
super().__init__(f"Service not found: {service_name}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoHealthyInstanceError(TapperException):
|
|
25
|
+
"""Raised when no healthy instances are available for a service."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, service_name: str):
|
|
28
|
+
self.service_name = service_name
|
|
29
|
+
super().__init__(f"No healthy instances available for service: {service_name}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoadBalancerError(TapperException):
|
|
33
|
+
"""Raised when there's an error in load balancer selection."""
|
|
34
|
+
|
|
35
|
+
pass
|
tapper/gateway.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""API Gateway for routing requests to registered services."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from starlette.datastructures import Headers
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import Response, StreamingResponse
|
|
13
|
+
from starlette.routing import compile_path
|
|
14
|
+
|
|
15
|
+
from tapper.exceptions import NoHealthyInstanceError, ServiceNotFoundError
|
|
16
|
+
from tapper.load_balancer import LoadBalancer, get_load_balancer
|
|
17
|
+
from tapper.models import ServiceInfo
|
|
18
|
+
from tapper.registry.client import RegistryClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
Scope = dict[str, Any]
|
|
23
|
+
Receive = Callable[[], Any]
|
|
24
|
+
Send = Callable[[dict[str, Any]], Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RouteEntry:
|
|
28
|
+
"""A compiled route entry for matching incoming requests."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path_pattern: str, methods: set[str], service: ServiceInfo):
|
|
31
|
+
self.path_pattern = path_pattern
|
|
32
|
+
self.methods = methods
|
|
33
|
+
self.service = service
|
|
34
|
+
self._regex, _, self._convertors = compile_path(path_pattern)
|
|
35
|
+
|
|
36
|
+
def match(self, path: str, method: str) -> dict[str, Any] | None:
|
|
37
|
+
"""Check if the given path and method match this route.
|
|
38
|
+
|
|
39
|
+
Returns path parameters if matched, None otherwise.
|
|
40
|
+
"""
|
|
41
|
+
if method not in self.methods:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
match = self._regex.match(path)
|
|
45
|
+
if match:
|
|
46
|
+
params = {}
|
|
47
|
+
for key, value in match.groupdict().items():
|
|
48
|
+
if key in self._convertors:
|
|
49
|
+
params[key] = self._convertors[key].convert(value)
|
|
50
|
+
else:
|
|
51
|
+
params[key] = value
|
|
52
|
+
return params
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TapperGateway:
|
|
57
|
+
"""ASGI application that acts as an API gateway.
|
|
58
|
+
|
|
59
|
+
Routes incoming requests to registered services based on their
|
|
60
|
+
declared routes, with load balancing across available instances.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
registry_url: str | None = None,
|
|
66
|
+
discovery_interval: int = 30,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
load_balancer: LoadBalancer | str = "round-robin",
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Initialize the gateway.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
registry_url: URL of the registry server.
|
|
74
|
+
Defaults to TAPPER_REGISTRY_URL env var.
|
|
75
|
+
discovery_interval: Seconds between service discovery refreshes.
|
|
76
|
+
timeout: Request timeout in seconds.
|
|
77
|
+
load_balancer: Load balancer instance or strategy name.
|
|
78
|
+
"""
|
|
79
|
+
self.registry_url = registry_url or os.environ.get(
|
|
80
|
+
"TAPPER_REGISTRY_URL", "http://localhost:8001"
|
|
81
|
+
)
|
|
82
|
+
self.discovery_interval = discovery_interval
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self.load_balancer = get_load_balancer(load_balancer)
|
|
85
|
+
|
|
86
|
+
self._client: RegistryClient | None = None
|
|
87
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
88
|
+
self._services: list[ServiceInfo] = []
|
|
89
|
+
self._routes: list[RouteEntry] = []
|
|
90
|
+
self._discovery_task: asyncio.Task | None = None
|
|
91
|
+
self._started = False
|
|
92
|
+
|
|
93
|
+
async def _get_client(self) -> RegistryClient:
|
|
94
|
+
"""Get or create the registry client."""
|
|
95
|
+
if self._client is None:
|
|
96
|
+
self._client = RegistryClient(self.registry_url)
|
|
97
|
+
return self._client
|
|
98
|
+
|
|
99
|
+
async def _get_http_client(self) -> httpx.AsyncClient:
|
|
100
|
+
"""Get or create the HTTP client for proxying."""
|
|
101
|
+
if self._http_client is None:
|
|
102
|
+
self._http_client = httpx.AsyncClient(timeout=self.timeout)
|
|
103
|
+
return self._http_client
|
|
104
|
+
|
|
105
|
+
async def _refresh_services(self) -> None:
|
|
106
|
+
"""Refresh the service list from the registry."""
|
|
107
|
+
try:
|
|
108
|
+
client = await self._get_client()
|
|
109
|
+
self._services = await client.get_services()
|
|
110
|
+
self._build_route_table()
|
|
111
|
+
logger.debug(f"Refreshed services: {[s.name for s in self._services]}")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"Failed to refresh services: {e}")
|
|
114
|
+
|
|
115
|
+
def _build_route_table(self) -> None:
|
|
116
|
+
"""Build the route table from registered services."""
|
|
117
|
+
routes = []
|
|
118
|
+
for service in self._services:
|
|
119
|
+
for route in service.routes:
|
|
120
|
+
routes.append(RouteEntry(
|
|
121
|
+
path_pattern=route.path,
|
|
122
|
+
methods=set(route.methods),
|
|
123
|
+
service=service,
|
|
124
|
+
))
|
|
125
|
+
self._routes = routes
|
|
126
|
+
|
|
127
|
+
def _match_route(self, path: str, method: str) -> tuple[ServiceInfo, dict[str, Any]] | None:
|
|
128
|
+
"""Find a matching route for the given path and method."""
|
|
129
|
+
for entry in self._routes:
|
|
130
|
+
params = entry.match(path, method)
|
|
131
|
+
if params is not None:
|
|
132
|
+
return entry.service, params
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
async def _discovery_loop(self) -> None:
|
|
136
|
+
"""Periodically refresh the service list."""
|
|
137
|
+
while True:
|
|
138
|
+
await self._refresh_services()
|
|
139
|
+
await asyncio.sleep(self.discovery_interval)
|
|
140
|
+
|
|
141
|
+
async def _startup(self) -> None:
|
|
142
|
+
"""Start background tasks."""
|
|
143
|
+
if self._started:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
await self._refresh_services()
|
|
147
|
+
self._discovery_task = asyncio.create_task(self._discovery_loop())
|
|
148
|
+
self._started = True
|
|
149
|
+
|
|
150
|
+
async def _shutdown(self) -> None:
|
|
151
|
+
"""Clean up resources."""
|
|
152
|
+
if self._discovery_task:
|
|
153
|
+
self._discovery_task.cancel()
|
|
154
|
+
try:
|
|
155
|
+
await self._discovery_task
|
|
156
|
+
except asyncio.CancelledError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
if self._client:
|
|
160
|
+
await self._client.close()
|
|
161
|
+
|
|
162
|
+
if self._http_client:
|
|
163
|
+
await self._http_client.aclose()
|
|
164
|
+
|
|
165
|
+
async def _proxy_request(
|
|
166
|
+
self,
|
|
167
|
+
request: Request,
|
|
168
|
+
service: ServiceInfo,
|
|
169
|
+
) -> Response:
|
|
170
|
+
"""Proxy a request to a service instance."""
|
|
171
|
+
if not service.instances:
|
|
172
|
+
raise NoHealthyInstanceError(service.name)
|
|
173
|
+
|
|
174
|
+
instance = self.load_balancer.select_instance(
|
|
175
|
+
service.instances,
|
|
176
|
+
service.name,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
target_url = f"{instance.url.rstrip('/')}{request.url.path}"
|
|
180
|
+
if request.url.query:
|
|
181
|
+
target_url = f"{target_url}?{request.url.query}"
|
|
182
|
+
|
|
183
|
+
headers = dict(request.headers)
|
|
184
|
+
headers.pop("host", None)
|
|
185
|
+
|
|
186
|
+
body = await request.body()
|
|
187
|
+
|
|
188
|
+
http_client = await self._get_http_client()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
response = await http_client.request(
|
|
192
|
+
method=request.method,
|
|
193
|
+
url=target_url,
|
|
194
|
+
headers=headers,
|
|
195
|
+
content=body,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
response_headers = dict(response.headers)
|
|
199
|
+
response_headers.pop("content-encoding", None)
|
|
200
|
+
response_headers.pop("content-length", None)
|
|
201
|
+
response_headers.pop("transfer-encoding", None)
|
|
202
|
+
|
|
203
|
+
return Response(
|
|
204
|
+
content=response.content,
|
|
205
|
+
status_code=response.status_code,
|
|
206
|
+
headers=response_headers,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
except httpx.TimeoutException:
|
|
210
|
+
logger.error(f"Timeout proxying to {target_url}")
|
|
211
|
+
return Response(
|
|
212
|
+
content=b'{"error": "Gateway timeout"}',
|
|
213
|
+
status_code=504,
|
|
214
|
+
media_type="application/json",
|
|
215
|
+
)
|
|
216
|
+
except httpx.RequestError as e:
|
|
217
|
+
logger.error(f"Error proxying to {target_url}: {e}")
|
|
218
|
+
return Response(
|
|
219
|
+
content=b'{"error": "Bad gateway"}',
|
|
220
|
+
status_code=502,
|
|
221
|
+
media_type="application/json",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
225
|
+
"""ASGI application entry point."""
|
|
226
|
+
if scope["type"] == "lifespan":
|
|
227
|
+
await self._handle_lifespan(scope, receive, send)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if scope["type"] != "http":
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
await self._startup()
|
|
234
|
+
|
|
235
|
+
request = Request(scope, receive, send)
|
|
236
|
+
|
|
237
|
+
if request.url.path == "/health":
|
|
238
|
+
response = Response(
|
|
239
|
+
content=b'{"status": "healthy"}',
|
|
240
|
+
status_code=200,
|
|
241
|
+
media_type="application/json",
|
|
242
|
+
)
|
|
243
|
+
await response(scope, receive, send)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
match = self._match_route(request.url.path, request.method)
|
|
247
|
+
|
|
248
|
+
if match is None:
|
|
249
|
+
response = Response(
|
|
250
|
+
content=b'{"error": "Not found"}',
|
|
251
|
+
status_code=404,
|
|
252
|
+
media_type="application/json",
|
|
253
|
+
)
|
|
254
|
+
await response(scope, receive, send)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
service, _ = match
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
response = await self._proxy_request(request, service)
|
|
261
|
+
await response(scope, receive, send)
|
|
262
|
+
except NoHealthyInstanceError:
|
|
263
|
+
response = Response(
|
|
264
|
+
content=b'{"error": "Service unavailable"}',
|
|
265
|
+
status_code=503,
|
|
266
|
+
media_type="application/json",
|
|
267
|
+
)
|
|
268
|
+
await response(scope, receive, send)
|
|
269
|
+
|
|
270
|
+
async def _handle_lifespan(
|
|
271
|
+
self,
|
|
272
|
+
scope: Scope,
|
|
273
|
+
receive: Receive,
|
|
274
|
+
send: Send,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Handle ASGI lifespan events."""
|
|
277
|
+
while True:
|
|
278
|
+
message = await receive()
|
|
279
|
+
|
|
280
|
+
if message["type"] == "lifespan.startup":
|
|
281
|
+
try:
|
|
282
|
+
await self._startup()
|
|
283
|
+
await send({"type": "lifespan.startup.complete"})
|
|
284
|
+
except Exception as e:
|
|
285
|
+
await send({
|
|
286
|
+
"type": "lifespan.startup.failed",
|
|
287
|
+
"message": str(e),
|
|
288
|
+
})
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
elif message["type"] == "lifespan.shutdown":
|
|
292
|
+
await self._shutdown()
|
|
293
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
294
|
+
return
|
tapper/load_balancer.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Load balancing strategies for service instances."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from tapper.exceptions import NoHealthyInstanceError
|
|
8
|
+
from tapper.models import ServiceInstance
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LoadBalancer(ABC):
|
|
12
|
+
"""Abstract base class for load balancing strategies."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def select_instance(
|
|
16
|
+
self,
|
|
17
|
+
instances: list[ServiceInstance],
|
|
18
|
+
service_name: str = "",
|
|
19
|
+
) -> ServiceInstance:
|
|
20
|
+
"""Select an instance from the list of available instances.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
instances: List of available service instances.
|
|
24
|
+
service_name: Name of the service (for stateful balancers).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The selected instance.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
NoHealthyInstanceError: If no healthy instances are available.
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def _filter_healthy(self, instances: list[ServiceInstance]) -> list[ServiceInstance]:
|
|
35
|
+
"""Filter to only healthy instances."""
|
|
36
|
+
return [inst for inst in instances if inst.healthy]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RoundRobinBalancer(LoadBalancer):
|
|
40
|
+
"""Round-robin load balancing strategy.
|
|
41
|
+
|
|
42
|
+
Distributes requests evenly across all healthy instances.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._counters: dict[str, int] = defaultdict(int)
|
|
47
|
+
|
|
48
|
+
def select_instance(
|
|
49
|
+
self,
|
|
50
|
+
instances: list[ServiceInstance],
|
|
51
|
+
service_name: str = "",
|
|
52
|
+
) -> ServiceInstance:
|
|
53
|
+
healthy = self._filter_healthy(instances)
|
|
54
|
+
if not healthy:
|
|
55
|
+
raise NoHealthyInstanceError(service_name)
|
|
56
|
+
|
|
57
|
+
index = self._counters[service_name] % len(healthy)
|
|
58
|
+
self._counters[service_name] += 1
|
|
59
|
+
return healthy[index]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RandomBalancer(LoadBalancer):
|
|
63
|
+
"""Random load balancing strategy.
|
|
64
|
+
|
|
65
|
+
Randomly selects from healthy instances.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def select_instance(
|
|
69
|
+
self,
|
|
70
|
+
instances: list[ServiceInstance],
|
|
71
|
+
service_name: str = "",
|
|
72
|
+
) -> ServiceInstance:
|
|
73
|
+
healthy = self._filter_healthy(instances)
|
|
74
|
+
if not healthy:
|
|
75
|
+
raise NoHealthyInstanceError(service_name)
|
|
76
|
+
|
|
77
|
+
return random.choice(healthy)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LeastConnectionsBalancer(LoadBalancer):
|
|
81
|
+
"""Least connections load balancing strategy.
|
|
82
|
+
|
|
83
|
+
Selects the instance with the fewest active connections.
|
|
84
|
+
Note: Requires external connection tracking.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._connections: dict[str, int] = defaultdict(int)
|
|
89
|
+
|
|
90
|
+
def select_instance(
|
|
91
|
+
self,
|
|
92
|
+
instances: list[ServiceInstance],
|
|
93
|
+
service_name: str = "",
|
|
94
|
+
) -> ServiceInstance:
|
|
95
|
+
healthy = self._filter_healthy(instances)
|
|
96
|
+
if not healthy:
|
|
97
|
+
raise NoHealthyInstanceError(service_name)
|
|
98
|
+
|
|
99
|
+
min_connections = float("inf")
|
|
100
|
+
selected = healthy[0]
|
|
101
|
+
|
|
102
|
+
for inst in healthy:
|
|
103
|
+
conn_count = self._connections[inst.url]
|
|
104
|
+
if conn_count < min_connections:
|
|
105
|
+
min_connections = conn_count
|
|
106
|
+
selected = inst
|
|
107
|
+
|
|
108
|
+
return selected
|
|
109
|
+
|
|
110
|
+
def increment(self, url: str) -> None:
|
|
111
|
+
"""Increment the connection count for an instance."""
|
|
112
|
+
self._connections[url] += 1
|
|
113
|
+
|
|
114
|
+
def decrement(self, url: str) -> None:
|
|
115
|
+
"""Decrement the connection count for an instance."""
|
|
116
|
+
if self._connections[url] > 0:
|
|
117
|
+
self._connections[url] -= 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_load_balancer(strategy: str | LoadBalancer) -> LoadBalancer:
|
|
121
|
+
"""Get a load balancer instance by name or return the provided instance.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
strategy: Either a LoadBalancer instance or a string name
|
|
125
|
+
("round-robin", "random", "least-connections").
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A LoadBalancer instance.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If the strategy name is unknown.
|
|
132
|
+
"""
|
|
133
|
+
if isinstance(strategy, LoadBalancer):
|
|
134
|
+
return strategy
|
|
135
|
+
|
|
136
|
+
strategies = {
|
|
137
|
+
"round-robin": RoundRobinBalancer,
|
|
138
|
+
"random": RandomBalancer,
|
|
139
|
+
"least-connections": LeastConnectionsBalancer,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if strategy not in strategies:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"Unknown load balancer strategy: {strategy}. "
|
|
145
|
+
f"Available: {list(strategies.keys())}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return strategies[strategy]()
|
tapper/models.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Pydantic models for the Tapper framework."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Route(BaseModel):
|
|
9
|
+
"""Represents an API route exposed by a service."""
|
|
10
|
+
|
|
11
|
+
path: str = Field(..., description="Route path pattern, e.g., '/users/{user_id}'")
|
|
12
|
+
methods: list[str] = Field(..., description="HTTP methods, e.g., ['GET', 'POST']")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ServiceInstance(BaseModel):
|
|
16
|
+
"""Represents a running instance of a service."""
|
|
17
|
+
|
|
18
|
+
url: str = Field(..., description="Base URL of the instance, e.g., 'http://localhost:8001'")
|
|
19
|
+
health_endpoint: str = Field(default="/health", description="Health check endpoint path")
|
|
20
|
+
last_heartbeat: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
21
|
+
healthy: bool = Field(default=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServiceInfo(BaseModel):
|
|
25
|
+
"""Complete information about a registered service."""
|
|
26
|
+
|
|
27
|
+
name: str = Field(..., description="Unique service name")
|
|
28
|
+
version: str = Field(..., description="Service version")
|
|
29
|
+
description: str | None = Field(default=None)
|
|
30
|
+
prefix: str | None = Field(default=None, description="URL prefix for routing")
|
|
31
|
+
routes: list[Route] = Field(default_factory=list)
|
|
32
|
+
instances: list[ServiceInstance] = Field(default_factory=list)
|
|
33
|
+
tags: list[str] = Field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RegisterRequest(BaseModel):
|
|
37
|
+
"""Request body for service registration."""
|
|
38
|
+
|
|
39
|
+
service: ServiceInfo
|
|
40
|
+
instance: ServiceInstance
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HeartbeatRequest(BaseModel):
|
|
44
|
+
"""Request body for heartbeat updates."""
|
|
45
|
+
|
|
46
|
+
instance_url: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnregisterRequest(BaseModel):
|
|
50
|
+
"""Request body for service unregistration."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
instance_url: str
|