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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Registry module for service discovery."""
|
|
2
|
+
|
|
3
|
+
from tapper.registry.backends import InMemoryBackend, RegistryBackend
|
|
4
|
+
from tapper.registry.client import RegistryClient
|
|
5
|
+
from tapper.registry.server import create_registry_app
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"RegistryBackend",
|
|
9
|
+
"InMemoryBackend",
|
|
10
|
+
"RegistryClient",
|
|
11
|
+
"create_registry_app",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from tapper.registry.backends import RedisBackend
|
|
16
|
+
__all__.append("RedisBackend")
|
|
17
|
+
except ImportError:
|
|
18
|
+
pass
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Registry backend implementations."""
|
|
2
|
+
|
|
3
|
+
from tapper.registry.backends.base import RegistryBackend
|
|
4
|
+
from tapper.registry.backends.memory import InMemoryBackend
|
|
5
|
+
|
|
6
|
+
__all__ = ["RegistryBackend", "InMemoryBackend"]
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from tapper.registry.backends.redis import RedisBackend
|
|
10
|
+
__all__.append("RedisBackend")
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Abstract base class for registry backends."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from tapper.models import ServiceInfo, ServiceInstance
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RegistryBackend(ABC):
|
|
9
|
+
"""Abstract base class for service registry backends."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def register(self, service: ServiceInfo, instance: ServiceInstance) -> None:
|
|
13
|
+
"""Register a service instance.
|
|
14
|
+
|
|
15
|
+
If the service already exists, adds the instance to it.
|
|
16
|
+
If the instance already exists, updates its information.
|
|
17
|
+
"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def unregister(self, name: str, instance_url: str) -> None:
|
|
22
|
+
"""Remove a service instance from the registry."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def get_service(self, name: str) -> ServiceInfo | None:
|
|
27
|
+
"""Get a service by name, or None if not found."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def get_all_services(self) -> list[ServiceInfo]:
|
|
32
|
+
"""Get all registered services."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def heartbeat(self, name: str, instance_url: str) -> None:
|
|
37
|
+
"""Update the heartbeat timestamp for a service instance."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def cleanup_stale(self, max_age_seconds: int = 60) -> None:
|
|
42
|
+
"""Remove instances that haven't sent a heartbeat within max_age_seconds."""
|
|
43
|
+
pass
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""In-memory registry backend for development and testing."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from tapper.models import ServiceInfo, ServiceInstance
|
|
7
|
+
from tapper.registry.backends.base import RegistryBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemoryBackend(RegistryBackend):
|
|
11
|
+
"""In-memory implementation of the registry backend.
|
|
12
|
+
|
|
13
|
+
Suitable for development and single-instance deployments.
|
|
14
|
+
Data is lost when the process stops.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._services: dict[str, ServiceInfo] = {}
|
|
19
|
+
self._lock = asyncio.Lock()
|
|
20
|
+
|
|
21
|
+
async def register(self, service: ServiceInfo, instance: ServiceInstance) -> None:
|
|
22
|
+
"""Register a service instance."""
|
|
23
|
+
async with self._lock:
|
|
24
|
+
if service.name in self._services:
|
|
25
|
+
existing = self._services[service.name]
|
|
26
|
+
instance_urls = {inst.url for inst in existing.instances}
|
|
27
|
+
if instance.url in instance_urls:
|
|
28
|
+
existing.instances = [
|
|
29
|
+
instance if inst.url == instance.url else inst
|
|
30
|
+
for inst in existing.instances
|
|
31
|
+
]
|
|
32
|
+
else:
|
|
33
|
+
existing.instances.append(instance)
|
|
34
|
+
existing.routes = service.routes
|
|
35
|
+
existing.version = service.version
|
|
36
|
+
existing.description = service.description
|
|
37
|
+
existing.prefix = service.prefix
|
|
38
|
+
existing.tags = service.tags
|
|
39
|
+
else:
|
|
40
|
+
service_copy = service.model_copy(deep=True)
|
|
41
|
+
service_copy.instances = [instance]
|
|
42
|
+
self._services[service.name] = service_copy
|
|
43
|
+
|
|
44
|
+
async def unregister(self, name: str, instance_url: str) -> None:
|
|
45
|
+
"""Remove a service instance from the registry."""
|
|
46
|
+
async with self._lock:
|
|
47
|
+
if name in self._services:
|
|
48
|
+
service = self._services[name]
|
|
49
|
+
service.instances = [
|
|
50
|
+
inst for inst in service.instances if inst.url != instance_url
|
|
51
|
+
]
|
|
52
|
+
if not service.instances:
|
|
53
|
+
del self._services[name]
|
|
54
|
+
|
|
55
|
+
async def get_service(self, name: str) -> ServiceInfo | None:
|
|
56
|
+
"""Get a service by name."""
|
|
57
|
+
async with self._lock:
|
|
58
|
+
service = self._services.get(name)
|
|
59
|
+
if service:
|
|
60
|
+
return service.model_copy(deep=True)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
async def get_all_services(self) -> list[ServiceInfo]:
|
|
64
|
+
"""Get all registered services."""
|
|
65
|
+
async with self._lock:
|
|
66
|
+
return [svc.model_copy(deep=True) for svc in self._services.values()]
|
|
67
|
+
|
|
68
|
+
async def heartbeat(self, name: str, instance_url: str) -> None:
|
|
69
|
+
"""Update the heartbeat timestamp for a service instance."""
|
|
70
|
+
async with self._lock:
|
|
71
|
+
if name in self._services:
|
|
72
|
+
for instance in self._services[name].instances:
|
|
73
|
+
if instance.url == instance_url:
|
|
74
|
+
instance.last_heartbeat = datetime.now(UTC)
|
|
75
|
+
instance.healthy = True
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
async def cleanup_stale(self, max_age_seconds: int = 60) -> None:
|
|
79
|
+
"""Remove instances that haven't sent a heartbeat within max_age_seconds."""
|
|
80
|
+
async with self._lock:
|
|
81
|
+
cutoff = datetime.now(UTC) - timedelta(seconds=max_age_seconds)
|
|
82
|
+
services_to_remove = []
|
|
83
|
+
|
|
84
|
+
for name, service in self._services.items():
|
|
85
|
+
service.instances = [
|
|
86
|
+
inst for inst in service.instances
|
|
87
|
+
if inst.last_heartbeat > cutoff
|
|
88
|
+
]
|
|
89
|
+
if not service.instances:
|
|
90
|
+
services_to_remove.append(name)
|
|
91
|
+
|
|
92
|
+
for name in services_to_remove:
|
|
93
|
+
del self._services[name]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Redis-based registry backend for distributed deployments."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from tapper.models import ServiceInfo, ServiceInstance
|
|
8
|
+
from tapper.registry.backends.base import RegistryBackend
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import redis.asyncio as redis
|
|
12
|
+
except ImportError:
|
|
13
|
+
redis = None # type: ignore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisBackend(RegistryBackend):
|
|
17
|
+
"""Redis-based implementation of the registry backend.
|
|
18
|
+
|
|
19
|
+
Suitable for distributed deployments where multiple registry instances
|
|
20
|
+
need to share state.
|
|
21
|
+
|
|
22
|
+
Requires the 'redis' optional dependency: pip install tapper[redis]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
SERVICE_KEY_PREFIX = "tapper:service:"
|
|
26
|
+
INSTANCE_KEY_PREFIX = "tapper:instance:"
|
|
27
|
+
DEFAULT_INSTANCE_TTL = 90 # seconds
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
redis_url: str = "redis://localhost:6379",
|
|
32
|
+
instance_ttl: int = DEFAULT_INSTANCE_TTL,
|
|
33
|
+
) -> None:
|
|
34
|
+
if redis is None:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"Redis support requires the 'redis' package. "
|
|
37
|
+
"Install with: pip install tapper[redis]"
|
|
38
|
+
)
|
|
39
|
+
self._redis_url = redis_url
|
|
40
|
+
self._instance_ttl = instance_ttl
|
|
41
|
+
self._client: redis.Redis | None = None
|
|
42
|
+
|
|
43
|
+
async def _get_client(self) -> "redis.Redis":
|
|
44
|
+
"""Get or create the Redis client."""
|
|
45
|
+
if self._client is None:
|
|
46
|
+
self._client = redis.from_url(self._redis_url)
|
|
47
|
+
return self._client
|
|
48
|
+
|
|
49
|
+
def _service_key(self, name: str) -> str:
|
|
50
|
+
"""Generate Redis key for service info."""
|
|
51
|
+
return f"{self.SERVICE_KEY_PREFIX}{name}"
|
|
52
|
+
|
|
53
|
+
def _instance_key(self, name: str, url: str) -> str:
|
|
54
|
+
"""Generate Redis key for service instance."""
|
|
55
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
|
56
|
+
return f"{self.INSTANCE_KEY_PREFIX}{name}:{url_hash}"
|
|
57
|
+
|
|
58
|
+
async def register(self, service: ServiceInfo, instance: ServiceInstance) -> None:
|
|
59
|
+
"""Register a service instance."""
|
|
60
|
+
client = await self._get_client()
|
|
61
|
+
|
|
62
|
+
service_data = service.model_dump(mode="json")
|
|
63
|
+
service_data["instances"] = []
|
|
64
|
+
await client.set(
|
|
65
|
+
self._service_key(service.name),
|
|
66
|
+
json.dumps(service_data),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
instance_data = instance.model_dump(mode="json")
|
|
70
|
+
await client.setex(
|
|
71
|
+
self._instance_key(service.name, instance.url),
|
|
72
|
+
self._instance_ttl,
|
|
73
|
+
json.dumps(instance_data),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def unregister(self, name: str, instance_url: str) -> None:
|
|
77
|
+
"""Remove a service instance from the registry."""
|
|
78
|
+
client = await self._get_client()
|
|
79
|
+
await client.delete(self._instance_key(name, instance_url))
|
|
80
|
+
|
|
81
|
+
instances = await self._get_instances(name)
|
|
82
|
+
if not instances:
|
|
83
|
+
await client.delete(self._service_key(name))
|
|
84
|
+
|
|
85
|
+
async def _get_instances(self, name: str) -> list[ServiceInstance]:
|
|
86
|
+
"""Get all instances for a service."""
|
|
87
|
+
client = await self._get_client()
|
|
88
|
+
pattern = f"{self.INSTANCE_KEY_PREFIX}{name}:*"
|
|
89
|
+
instances = []
|
|
90
|
+
|
|
91
|
+
async for key in client.scan_iter(match=pattern):
|
|
92
|
+
data = await client.get(key)
|
|
93
|
+
if data:
|
|
94
|
+
instance_data = json.loads(data)
|
|
95
|
+
instance_data["last_heartbeat"] = datetime.fromisoformat(
|
|
96
|
+
instance_data["last_heartbeat"]
|
|
97
|
+
)
|
|
98
|
+
instances.append(ServiceInstance(**instance_data))
|
|
99
|
+
|
|
100
|
+
return instances
|
|
101
|
+
|
|
102
|
+
async def get_service(self, name: str) -> ServiceInfo | None:
|
|
103
|
+
"""Get a service by name."""
|
|
104
|
+
client = await self._get_client()
|
|
105
|
+
data = await client.get(self._service_key(name))
|
|
106
|
+
|
|
107
|
+
if not data:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
service_data = json.loads(data)
|
|
111
|
+
instances = await self._get_instances(name)
|
|
112
|
+
service_data["instances"] = [inst.model_dump() for inst in instances]
|
|
113
|
+
|
|
114
|
+
return ServiceInfo(**service_data)
|
|
115
|
+
|
|
116
|
+
async def get_all_services(self) -> list[ServiceInfo]:
|
|
117
|
+
"""Get all registered services."""
|
|
118
|
+
client = await self._get_client()
|
|
119
|
+
pattern = f"{self.SERVICE_KEY_PREFIX}*"
|
|
120
|
+
services = []
|
|
121
|
+
|
|
122
|
+
async for key in client.scan_iter(match=pattern):
|
|
123
|
+
name = key.decode() if isinstance(key, bytes) else key
|
|
124
|
+
name = name.replace(self.SERVICE_KEY_PREFIX, "")
|
|
125
|
+
service = await self.get_service(name)
|
|
126
|
+
if service:
|
|
127
|
+
services.append(service)
|
|
128
|
+
|
|
129
|
+
return services
|
|
130
|
+
|
|
131
|
+
async def heartbeat(self, name: str, instance_url: str) -> None:
|
|
132
|
+
"""Update the heartbeat timestamp for a service instance."""
|
|
133
|
+
client = await self._get_client()
|
|
134
|
+
key = self._instance_key(name, instance_url)
|
|
135
|
+
data = await client.get(key)
|
|
136
|
+
|
|
137
|
+
if data:
|
|
138
|
+
instance_data = json.loads(data)
|
|
139
|
+
instance_data["last_heartbeat"] = datetime.now(UTC).isoformat()
|
|
140
|
+
instance_data["healthy"] = True
|
|
141
|
+
await client.setex(key, self._instance_ttl, json.dumps(instance_data))
|
|
142
|
+
|
|
143
|
+
async def cleanup_stale(self, max_age_seconds: int = 60) -> None:
|
|
144
|
+
"""Remove stale instances.
|
|
145
|
+
|
|
146
|
+
Note: With Redis, TTL handles automatic expiration.
|
|
147
|
+
This method is provided for manual cleanup if needed.
|
|
148
|
+
"""
|
|
149
|
+
client = await self._get_client()
|
|
150
|
+
cutoff = datetime.now(UTC) - timedelta(seconds=max_age_seconds)
|
|
151
|
+
|
|
152
|
+
for service in await self.get_all_services():
|
|
153
|
+
for instance in service.instances:
|
|
154
|
+
if instance.last_heartbeat < cutoff:
|
|
155
|
+
await client.delete(
|
|
156
|
+
self._instance_key(service.name, instance.url)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def close(self) -> None:
|
|
160
|
+
"""Close the Redis connection."""
|
|
161
|
+
if self._client:
|
|
162
|
+
await self._client.close()
|
|
163
|
+
self._client = None
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Async client for communicating with the service registry."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from tapper.exceptions import RegistryError
|
|
9
|
+
from tapper.models import ServiceInfo, ServiceInstance
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RegistryClient:
|
|
15
|
+
"""Async client for the Tapper service registry.
|
|
16
|
+
|
|
17
|
+
Handles registration, unregistration, heartbeats, and service discovery.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
registry_url: str,
|
|
23
|
+
timeout: float = 10.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Initialize the registry client.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
registry_url: Base URL of the registry server.
|
|
29
|
+
timeout: Request timeout in seconds.
|
|
30
|
+
"""
|
|
31
|
+
self._registry_url = registry_url.rstrip("/")
|
|
32
|
+
self._timeout = timeout
|
|
33
|
+
self._client: httpx.AsyncClient | None = None
|
|
34
|
+
self._heartbeat_task: asyncio.Task | None = None
|
|
35
|
+
|
|
36
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
37
|
+
"""Get or create the HTTP client."""
|
|
38
|
+
if self._client is None:
|
|
39
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
40
|
+
return self._client
|
|
41
|
+
|
|
42
|
+
async def register(
|
|
43
|
+
self,
|
|
44
|
+
service: ServiceInfo,
|
|
45
|
+
instance: ServiceInstance,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Register a service instance with the registry.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
service: Service information including routes.
|
|
51
|
+
instance: Instance-specific information.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
RegistryError: If registration fails.
|
|
55
|
+
"""
|
|
56
|
+
client = await self._get_client()
|
|
57
|
+
try:
|
|
58
|
+
response = await client.post(
|
|
59
|
+
f"{self._registry_url}/register",
|
|
60
|
+
json={
|
|
61
|
+
"service": service.model_dump(mode="json"),
|
|
62
|
+
"instance": instance.model_dump(mode="json"),
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
except httpx.HTTPError as e:
|
|
67
|
+
raise RegistryError(f"Failed to register service: {e}") from e
|
|
68
|
+
|
|
69
|
+
async def unregister(self, name: str, instance_url: str) -> None:
|
|
70
|
+
"""Remove a service instance from the registry.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
name: Service name.
|
|
74
|
+
instance_url: URL of the instance to unregister.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
RegistryError: If unregistration fails.
|
|
78
|
+
"""
|
|
79
|
+
client = await self._get_client()
|
|
80
|
+
try:
|
|
81
|
+
response = await client.post(
|
|
82
|
+
f"{self._registry_url}/unregister",
|
|
83
|
+
json={"name": name, "instance_url": instance_url},
|
|
84
|
+
)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
except httpx.HTTPError as e:
|
|
87
|
+
raise RegistryError(f"Failed to unregister service: {e}") from e
|
|
88
|
+
|
|
89
|
+
async def get_services(self) -> list[ServiceInfo]:
|
|
90
|
+
"""Get all registered services from the registry.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of registered services.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
RegistryError: If the request fails.
|
|
97
|
+
"""
|
|
98
|
+
client = await self._get_client()
|
|
99
|
+
try:
|
|
100
|
+
response = await client.get(f"{self._registry_url}/services")
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
return [ServiceInfo(**svc) for svc in response.json()]
|
|
103
|
+
except httpx.HTTPError as e:
|
|
104
|
+
raise RegistryError(f"Failed to get services: {e}") from e
|
|
105
|
+
|
|
106
|
+
async def get_service(self, name: str) -> ServiceInfo | None:
|
|
107
|
+
"""Get a specific service by name.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: Service name.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Service information or None if not found.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
RegistryError: If the request fails (except 404).
|
|
117
|
+
"""
|
|
118
|
+
client = await self._get_client()
|
|
119
|
+
try:
|
|
120
|
+
response = await client.get(f"{self._registry_url}/services/{name}")
|
|
121
|
+
if response.status_code == 404:
|
|
122
|
+
return None
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
return ServiceInfo(**response.json())
|
|
125
|
+
except httpx.HTTPError as e:
|
|
126
|
+
raise RegistryError(f"Failed to get service: {e}") from e
|
|
127
|
+
|
|
128
|
+
async def heartbeat(self, name: str, instance_url: str) -> None:
|
|
129
|
+
"""Send a heartbeat for a service instance.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
name: Service name.
|
|
133
|
+
instance_url: URL of the instance.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
RegistryError: If the heartbeat fails.
|
|
137
|
+
"""
|
|
138
|
+
client = await self._get_client()
|
|
139
|
+
try:
|
|
140
|
+
response = await client.post(
|
|
141
|
+
f"{self._registry_url}/heartbeat/{name}",
|
|
142
|
+
json={"instance_url": instance_url},
|
|
143
|
+
)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
except httpx.HTTPError as e:
|
|
146
|
+
logger.warning(f"Heartbeat failed for {name}: {e}")
|
|
147
|
+
raise RegistryError(f"Heartbeat failed: {e}") from e
|
|
148
|
+
|
|
149
|
+
def start_heartbeat(
|
|
150
|
+
self,
|
|
151
|
+
name: str,
|
|
152
|
+
instance_url: str,
|
|
153
|
+
interval: float = 30.0,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Start a background task to send periodic heartbeats.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
name: Service name.
|
|
159
|
+
instance_url: URL of the instance.
|
|
160
|
+
interval: Seconds between heartbeats.
|
|
161
|
+
"""
|
|
162
|
+
if self._heartbeat_task is not None:
|
|
163
|
+
self._heartbeat_task.cancel()
|
|
164
|
+
|
|
165
|
+
async def heartbeat_loop() -> None:
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
await self.heartbeat(name, instance_url)
|
|
169
|
+
except RegistryError:
|
|
170
|
+
pass # Already logged in heartbeat()
|
|
171
|
+
await asyncio.sleep(interval)
|
|
172
|
+
|
|
173
|
+
self._heartbeat_task = asyncio.create_task(heartbeat_loop())
|
|
174
|
+
|
|
175
|
+
async def stop_heartbeat(self) -> None:
|
|
176
|
+
"""Stop the background heartbeat task."""
|
|
177
|
+
if self._heartbeat_task is not None:
|
|
178
|
+
self._heartbeat_task.cancel()
|
|
179
|
+
try:
|
|
180
|
+
await self._heartbeat_task
|
|
181
|
+
except asyncio.CancelledError:
|
|
182
|
+
pass
|
|
183
|
+
self._heartbeat_task = None
|
|
184
|
+
|
|
185
|
+
async def close(self) -> None:
|
|
186
|
+
"""Close the client and stop any background tasks."""
|
|
187
|
+
await self.stop_heartbeat()
|
|
188
|
+
if self._client is not None:
|
|
189
|
+
await self._client.aclose()
|
|
190
|
+
self._client = None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""FastAPI-based service registry server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException, status
|
|
8
|
+
|
|
9
|
+
from tapper.models import (
|
|
10
|
+
HeartbeatRequest,
|
|
11
|
+
RegisterRequest,
|
|
12
|
+
ServiceInfo,
|
|
13
|
+
UnregisterRequest,
|
|
14
|
+
)
|
|
15
|
+
from tapper.registry.backends.base import RegistryBackend
|
|
16
|
+
from tapper.registry.backends.memory import InMemoryBackend
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_registry_app(
|
|
20
|
+
backend: RegistryBackend | None = None,
|
|
21
|
+
cleanup_interval: int = 30,
|
|
22
|
+
stale_threshold: int = 60,
|
|
23
|
+
) -> FastAPI:
|
|
24
|
+
"""Create a FastAPI application for the service registry.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
backend: Registry backend to use. Defaults to InMemoryBackend.
|
|
28
|
+
cleanup_interval: Seconds between stale instance cleanup runs.
|
|
29
|
+
stale_threshold: Seconds after which an instance is considered stale.
|
|
30
|
+
"""
|
|
31
|
+
if backend is None:
|
|
32
|
+
backend = InMemoryBackend()
|
|
33
|
+
|
|
34
|
+
cleanup_task: asyncio.Task | None = None
|
|
35
|
+
|
|
36
|
+
async def cleanup_loop() -> None:
|
|
37
|
+
"""Periodically clean up stale service instances."""
|
|
38
|
+
while True:
|
|
39
|
+
await asyncio.sleep(cleanup_interval)
|
|
40
|
+
await backend.cleanup_stale(stale_threshold)
|
|
41
|
+
|
|
42
|
+
@asynccontextmanager
|
|
43
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
44
|
+
nonlocal cleanup_task
|
|
45
|
+
cleanup_task = asyncio.create_task(cleanup_loop())
|
|
46
|
+
yield
|
|
47
|
+
if cleanup_task:
|
|
48
|
+
cleanup_task.cancel()
|
|
49
|
+
try:
|
|
50
|
+
await cleanup_task
|
|
51
|
+
except asyncio.CancelledError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
app = FastAPI(
|
|
55
|
+
title="Tapper Service Registry",
|
|
56
|
+
description="Central registry for service discovery",
|
|
57
|
+
version="0.1.0",
|
|
58
|
+
lifespan=lifespan,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@app.post("/register", status_code=status.HTTP_201_CREATED)
|
|
62
|
+
async def register(request: RegisterRequest) -> dict:
|
|
63
|
+
"""Register a service instance with the registry."""
|
|
64
|
+
await backend.register(request.service, request.instance)
|
|
65
|
+
return {"status": "registered", "service": request.service.name}
|
|
66
|
+
|
|
67
|
+
@app.post("/unregister", status_code=status.HTTP_200_OK)
|
|
68
|
+
async def unregister(request: UnregisterRequest) -> dict:
|
|
69
|
+
"""Remove a service instance from the registry."""
|
|
70
|
+
await backend.unregister(request.name, request.instance_url)
|
|
71
|
+
return {"status": "unregistered", "service": request.name}
|
|
72
|
+
|
|
73
|
+
@app.get("/services", response_model=list[ServiceInfo])
|
|
74
|
+
async def list_services() -> list[ServiceInfo]:
|
|
75
|
+
"""List all registered services."""
|
|
76
|
+
return await backend.get_all_services()
|
|
77
|
+
|
|
78
|
+
@app.get("/services/{name}", response_model=ServiceInfo)
|
|
79
|
+
async def get_service(name: str) -> ServiceInfo:
|
|
80
|
+
"""Get a specific service by name."""
|
|
81
|
+
service = await backend.get_service(name)
|
|
82
|
+
if service is None:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
85
|
+
detail=f"Service not found: {name}",
|
|
86
|
+
)
|
|
87
|
+
return service
|
|
88
|
+
|
|
89
|
+
@app.post("/heartbeat/{name}", status_code=status.HTTP_200_OK)
|
|
90
|
+
async def heartbeat(name: str, request: HeartbeatRequest) -> dict:
|
|
91
|
+
"""Update heartbeat for a service instance."""
|
|
92
|
+
service = await backend.get_service(name)
|
|
93
|
+
if service is None:
|
|
94
|
+
raise HTTPException(
|
|
95
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
96
|
+
detail=f"Service not found: {name}",
|
|
97
|
+
)
|
|
98
|
+
await backend.heartbeat(name, request.instance_url)
|
|
99
|
+
return {"status": "ok"}
|
|
100
|
+
|
|
101
|
+
@app.get("/health")
|
|
102
|
+
async def health() -> dict:
|
|
103
|
+
"""Health check endpoint for the registry itself."""
|
|
104
|
+
return {"status": "healthy"}
|
|
105
|
+
|
|
106
|
+
return app
|