Microservices · lesson 1 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
What a Microservice Actually Is: Two Real Services Talking Over HTTP
A microservice isn't a diagram — it's two small processes that only know each other's address. Two stdlib HTTP servers, one real request between them.
By SkillByExample · Published
Two services, two ports, one real HTTP call between them — this is the whole idea before any pattern gets layered on top.
Two services, one call
import http.server
import threading
import time
import urllib.request
class InventoryHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"42 units in stock")
def log_message(self, *args):
pass
server = http.server.HTTPServer(("localhost", 8001), InventoryHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
response = urllib.request.urlopen("http://localhost:8001/inventory")
print(f"order-service received: {response.read().decode()}")
print(f"status code: {response.status}")
server.shutdown()
$ python two_services.py
order-service received: 42 units in stock
status code: 200
inventory-service is nothing more than an HTTPServer bound to port 8001, running in a
background thread. order-service is the four lines after time.sleep(0.2) — a real HTTP
client making a real request to localhost:8001. Neither side imports the other’s code;
the only thing connecting them is a port number and the HTTP protocol. That’s the entire
definition of a microservice boundary: if two pieces of a system can only reach each other
over the network, they’re already microservices, whether or not you’ve adopted any framework
for it.
The time.sleep(0.2) matters more than it looks. serve_forever() needs a moment to actually
bind and start listening on its thread before the client tries to connect — skip the sleep and
the request sometimes races the server’s startup and fails.
Practice
1. Add a /health endpoint that returns "OK", and call it before the inventory endpoint.
import http.server
import threading
import time
import urllib.request
class InventoryHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
if self.path == "/health":
self.wfile.write(b"OK")
else:
self.wfile.write(b"42 units in stock")
def log_message(self, *args):
pass
server = http.server.HTTPServer(("localhost", 8001), InventoryHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
health = urllib.request.urlopen("http://localhost:8001/health")
print(f"health check: {health.read().decode()}")
inventory = urllib.request.urlopen("http://localhost:8001/inventory")
print(f"inventory: {inventory.read().decode()}")
server.shutdown()
$ python health_check.py
health check: OK
inventory: 42 units in stock
self.path is how a stdlib handler tells requests apart — there’s no routing framework here,
just an if on the raw path string.
2. Make inventory-service return JSON instead of plain text, and parse it on the client side.
import http.server
import json
import threading
import time
import urllib.request
class InventoryHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"item": "widget", "count": 42}).encode())
def log_message(self, *args):
pass
server = http.server.HTTPServer(("localhost", 8001), InventoryHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
response = urllib.request.urlopen("http://localhost:8001/inventory")
data = json.loads(response.read())
print(f"order-service parsed: {data}")
print(f"item type: {type(data)}")
server.shutdown()
$ python json_inventory.py
order-service parsed: {'item': 'widget', 'count': 42}
item type: <class 'dict'>
Almost every real service speaks JSON instead of plain text for exactly this reason — the caller gets a structured value back instead of a string it has to parse by hand.
3. Point the client at a port with nothing listening and print the real error.
import urllib.error
import urllib.request
try:
urllib.request.urlopen("http://localhost:9999/inventory", timeout=1)
except urllib.error.URLError as e:
print(f"Error: {e.reason}")
$ python dead_port.py
Error: timed out
Textbook advice says a closed port should fail fast with “connection refused.” On this
machine’s network stack it doesn’t — the connection attempt just hangs until the timeout=1
gives up. Both failure modes are real; a caller that only handles one of them will get caught
out by the other in production.
4. Send a POST request with a JSON body instead of a GET.
import http.server
import json
import threading
import time
import urllib.request
class OrderHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers["Content-Length"])
body = json.loads(self.rfile.read(length))
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"order_id": 501, "item": body["item"]}).encode())
def log_message(self, *args):
pass
server = http.server.HTTPServer(("localhost", 8005), OrderHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
payload = json.dumps({"item": "widget"}).encode()
request = urllib.request.Request("http://localhost:8005/orders", data=payload, method="POST")
response = urllib.request.urlopen(request)
print(f"status: {response.status}")
print(f"response: {json.loads(response.read())}")
server.shutdown()
$ python post_order.py
status: 201
response: {'order_id': 501, 'item': 'widget'}
urlopen sends a GET unless you hand it a Request object with method="POST" and a data
payload — the 201 status is the handler’s way of saying “created,” not just “OK.”
Next: what happens when a service address isn’t hardcoded — a tiny service registry that services register with and clients resolve through.