Microservices · lesson 2 of 8
Beginner
2Intermediate
5Also part of Microservices
9- + Idempotent Consumers: Surviving a Duplicate Delivery from Event-Driven Architecture
- + Transactional Outbox: Committing the Event With the Data from Event-Driven Architecture
- + Scaling & Reliability: Retries With Backoff and Jitter from System Design
- + Rate Limiting: Token Bucket and the Fixed-Window Boundary Bug from System Design
- + Docker Compose from Docker
- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + OpenTelemetry Buffers Spans — a SIGKILL Can Lose Them from Monitoring & Observability
- + Events vs Requests: Why the Order Service Doesn't Wait for Email from Event-Driven Architecture
- + Contract Testing with OpenAPI, and Running in CI from API Testing
Service Discovery: A Registry Clients Resolve Through
Hardcoded ports don't survive a restart. A tiny in-memory registry where services register their address and clients resolve it by name instead.
By SkillByExample · Published
localhost:8001 only works until that port changes — a registry lets a client ask for a service by name and get back whatever address is currently valid.
Register, resolve, deregister
class ServiceRegistry:
def __init__(self):
self._services = {}
def register(self, name, address):
self._services.setdefault(name, []).append(address)
print(f"Registered {name} at {address}")
def deregister(self, name, address):
self._services[name].remove(address)
print(f"Deregistered {name} at {address}")
def resolve(self, name):
addresses = self._services.get(name, [])
if not addresses:
raise LookupError(f"no instances registered for '{name}'")
return addresses[0]
registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
registry.register("inventory-service", "localhost:8002")
print(f"Client resolved inventory-service -> {registry.resolve('inventory-service')}")
registry.deregister("inventory-service", "localhost:8001")
print(f"Client resolved inventory-service -> {registry.resolve('inventory-service')}")
try:
registry.resolve("payment-service")
except LookupError as e:
print(f"Error: {e}")
$ python service_registry.py
Registered inventory-service at localhost:8001
Registered inventory-service at localhost:8002
Client resolved inventory-service -> localhost:8001
Deregistered inventory-service at localhost:8001
Client resolved inventory-service -> localhost:8002
Error: no instances registered for 'payment-service'
resolve always returns addresses[0] here, so once localhost:8001 is deregistered the
same call returns localhost:8002 without the client changing a single line — the client
never hardcoded a port, it only ever asked the registry for “inventory-service.” That
indirection is the entire value: services can restart, move, or scale up and down, and every
caller keeps working as long as it goes through the registry instead of a literal address.
Practice
1. Register three addresses for one service and round-robin across them.
class ServiceRegistry:
def __init__(self):
self._services = {}
self._next_index = {}
def register(self, name, address):
self._services.setdefault(name, []).append(address)
def resolve(self, name):
addresses = self._services.get(name, [])
if not addresses:
raise LookupError(f"no instances registered for '{name}'")
index = self._next_index.get(name, 0)
address = addresses[index % len(addresses)]
self._next_index[name] = index + 1
return address
registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
registry.register("inventory-service", "localhost:8002")
registry.register("inventory-service", "localhost:8003")
for i in range(5):
print(f"request {i + 1} -> {registry.resolve('inventory-service')}")
$ python round_robin.py
request 1 -> localhost:8001
request 2 -> localhost:8002
request 3 -> localhost:8003
request 4 -> localhost:8001
request 5 -> localhost:8002
index % len(addresses) wraps back to the start once every address has had a turn — request 4
lands on localhost:8001 again. This is client-side load balancing, the same idea a real
load balancer applies at the network layer instead of inside the calling code.
2. Add a list_services method that shows every registered name and its addresses.
class ServiceRegistry:
def __init__(self):
self._services = {}
def register(self, name, address):
self._services.setdefault(name, []).append(address)
def list_services(self):
return {name: list(addrs) for name, addrs in self._services.items()}
registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
registry.register("payment-service", "localhost:8002")
registry.register("inventory-service", "localhost:8003")
print(registry.list_services())
$ python list_services.py
{'inventory-service': ['localhost:8001', 'localhost:8003'], 'payment-service': ['localhost:8002']}
Returning list(addrs) instead of the internal list directly means a caller who mutates the
result can’t accidentally corrupt the registry’s own state.
3. Deregister an address that was never registered, and see what actually happens.
class ServiceRegistry:
def __init__(self):
self._services = {}
def register(self, name, address):
self._services.setdefault(name, []).append(address)
def deregister(self, name, address):
self._services[name].remove(address)
registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
try:
registry.deregister("inventory-service", "localhost:9999")
except ValueError as e:
print(f"Error: {e}")
$ python bad_deregister.py
Error: list.remove(x): x not in list
That’s Python’s own list.remove error leaking straight through — the registry does no
validation of its own before calling it. A production registry would catch this and raise
something more specific, which is exactly what the next task does.
4. Replace the generic LookupError with a custom ServiceNotFoundError.
class ServiceNotFoundError(Exception):
pass
class ServiceRegistry:
def __init__(self):
self._services = {}
def register(self, name, address):
self._services.setdefault(name, []).append(address)
def resolve(self, name):
addresses = self._services.get(name, [])
if not addresses:
raise ServiceNotFoundError(f"no instances registered for '{name}'")
return addresses[0]
registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
try:
registry.resolve("shipping-service")
except ServiceNotFoundError as e:
print(f"Error: {e}")
$ python custom_exception.py
Error: no instances registered for 'shipping-service'
A caller can now catch ServiceNotFoundError specifically instead of the generic
LookupError, which matters once the same codebase has other kinds of lookups that shouldn’t
be confused with a missing service.
Next: what a client should do when the address it resolves stops responding — a circuit breaker that stops calling a failing service instead of retrying it forever.