Skip to main content
Microservices · lesson 1 of 8
Microservices beginner Lesson 1 of 8

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

microservices http python

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.

Frequently Asked Questions

Do I need Docker or a framework to try this?
No. Every example in this topic uses Python's standard library (`http.server`, `urllib.request`) so you can run two 'services' as two threads in one file, or as two separate scripts in two terminals — no Flask, no Docker, no account needed.
Is a thread inside one process really a separate microservice?
Not in production, but for learning the communication pattern it's equivalent: each `HTTPServer` here binds its own port and only exposes state through HTTP responses, exactly like two genuinely separate processes would. Swap `threading.Thread` for two terminal windows and nothing else in the code changes.
Why does order-service need to know inventory-service's port number?
Because that's the whole problem microservices architecture has to solve: services only know each other by network address, not by importing each other's code. Lesson 2 in this topic replaces the hardcoded port with a lookup through a service registry.
What happens if inventory-service isn't running yet when order-service starts?
The request fails — usually with a connection error, though on some networks (including the one these lessons were tested on) a closed port causes the request to hang until it times out rather than fail immediately. Either way, the caller has to handle it; that failure mode is exactly what the Circuit Breaker lessons later in this topic are for.