15 min

Back to projectsView on GitHub

Distributed Microservices Orchestration & Resilience Engine

A successful request is easy to reason about. The interesting part starts when one downstream service becomes slow, flaky, or unavailable.

This project is a small three-service gRPC system built specifically to sit inside that second sentence — not to book real flights, but to make retries, a circuit breaker, and secured service-to-service calls into something you can actually watch happen, not just something the code claims to do.

Three services model a tiny flight-booking flow, talking to each other over and . An Orchestrator receives a booking request and coordinates two others: a User service that checks whether the caller is allowed to book, and a Search service that returns flight options.

The booking logic itself was never the point. It exists to give three real services a real reason to call each other constantly, so that everything wrapped around those calls — with , a , on the inter-service hops, and OpenTelemetry/// observability — has something genuine to protect and something genuine to show.

Once the three services existed and could talk to each other, the interesting engineering question stopped being "does this work" and became: what does the system actually do when Search — the one dependency it's realistic to make flaky on purpose — starts failing? Answering that honestly meant building a way to watch it happen, not just handling the exception and moving on.

Assumed knowledge: basic client-server networking, APIs, and synchronous request/response communication. gRPC, retries, circuit breakers, mTLS, and observability concepts are explained where they matter to this system.

What must remain true?

  • A client request should fail fast once a dependency is confirmed unhealthy, rather than hang indefinitely.Partial
  • One unhealthy dependency should not keep consuming caller resources with unbounded retries.Bounded
  • Backend-to-backend hops (Orchestrator→User, Orchestrator→Search) are mutually authenticated and encrypted.Protected
  • The client-facing Orchestrator edge (Client→Orchestrator) is encrypted and authenticated.Known gap
  • Latency and failures should be traceable across every service boundary a request crosses.Instrumented
  • Where the implementation falls short of these, that gap should be visible in the system's own design — not smoothed over.By design

What the first working version looked like

The request path is short — one client-facing hop and two inter-service hops — but each of the three edges behaves differently, and that difference is most of what this project is actually about.

Diagram of the gRPC request flow: a Client calling the Orchestrator Service over insecure gRPC, the Orchestrator calling the User Service and Search Service over secure mTLS, with a Resilience Logic box showing retry with exponential backoff and a circuit breaker scoped to Search calls only.
The request path as built: one unencrypted client-facing edge, two mTLS-protected inter-service hops, and resilience logic scoped to the Search call only.

Step through the same path one request at a time:

Step 1 of 8

Client calls Orchestrator

BookFlight (or StreamFlightPrices) arrives on the Orchestrator's insecure inbound port on 50053 — nothing about this hop is encrypted or authenticated.

Now break the system

Search has a commented-out block that, when manually enabled, makes it fail randomly about half the time — built specifically so the circuit breaker's behavior could be watched directly instead of inferred from reading the code.

Step 1 of 8

The failure switch gets flipped on

A block at the top of Search's method gets manually uncommented: on every call, there's now roughly a 50% chance it throws an exception. There's no environment variable for this — it's a code comment toggled by hand.

How I made the system observable

Diagram of the gRPC observability flow: User, Search, and Orchestrator services each exposing a /metrics endpoint scraped by Prometheus, which feeds Grafana dashboards, while OpenTelemetry trace data flows separately and directly from each service to Jaeger.
Two separate paths out of every service: metrics flow into Prometheus (then Grafana); traces flow, independently, straight to Jaeger.

Each of the three services exposes its own /metrics endpoint — User on :8000, Search on :8001, Orchestrator on :8002 — which Prometheus scrapes on an interval. Grafana never talks to a service directly; it builds its dashboards entirely from what's already sitting in Prometheus.

take a completely separate path. Each service's OpenTelemetry instrumentation exports data over OTLP directly to Jaeger — that data never passes through Prometheus or Grafana at all.

Why both: Metrics and traces answer different questions. A Grafana dashboard tells you that something changed — an error rate or a latency percentile moved. A Jaeger trace tells you which specific hop, on which specific request, actually slowed down or failed, and in what order.

This setup makes those changes observable: Grafana can surface changes in latency, error rate, and throughput, while Jaeger can show where an individual request spent time and whether downstream calls were executed at all — the difference between a request that failed after actually reaching Search and one the breaker rejected before it ever left the Orchestrator.

When Search is failing, these tools make it easier to correlate service-level symptoms with the path taken by a specific request, instead of reasoning about the failure from logs alone.

One honest asymmetry: the observability stack (Prometheus, Grafana, Jaeger) runs through Docker Compose, but the three gRPC services themselves currently run as local Python processes, not containers.

How the resilience logic actually behaves

Each mechanism is simple in isolation. What's less obvious is how they behave once they're stacked on top of each other on the same call.

Retries, with exponential backoff

The call to Search gets up to 3 attempts. Between attempts, the wait doubles instead of staying fixed — 1 second, then 2 — so a struggling service isn't hit with the exact same request rate it was already failing under.

User validation also retries, but with no circuit breaker behind it — a rejected or slow user check isn't the failure mode this project set out to protect against.

The circuit breaker's state machine

Three states: CLOSED (normal), OPEN (reject instantly, no network call), and (let exactly one probe through after cooldown). 3 consecutive failures trip it open; the cooldown is 10 seconds.

One detail that isn't obvious from the state diagram alone: because retry rechecks the breaker fresh on every attempt rather than once up front, the breaker's state can change mid-retry — a request can genuinely be the one whose own third attempt is what trips it.

Why Search is the protected dependency

Search is the one call deliberately built with a failure switch, and it's the dependency that sits on both the normal lookup path (SearchFlights, which returns a fixed flight list) and the streaming path (StreamFlightPrices, which produces changing prices over the stream) — a more realistic dependency to stress than a single boolean check, even though neither call is backed by a real search engine or datastore.

Why these mechanisms have to be understood together

Retry wrapping the breaker, instead of the reverse, has a real consequence once the breaker is open: a request still runs its full 3 attempts and their waits before learning each one failed instantly, costing roughly 3 seconds for a request that never reached the network. Reading the retry logic or the breaker logic alone wouldn't predict that — it only shows up once you trace one request through both.

Why observability isn't optional here

Without traces and metrics, a tripped breaker is invisible from the outside — it just looks like some requests got faster (rejected instantly) and others got slower (retried), with no explanation connecting the two. Observability is what turns "requests started failing" into "the breaker opened because of 3 consecutive Search failures, and here's the exact trace where that happened."

A few more decisions worth explaining

gRPC and protobuf over REST for inter-service calls

These calls happen constantly and automatically between backend services, not once per user click, so an explicit typed contract mattered more than the convenience of a plain JSON endpoint. Service methods and message shapes are defined once in .proto files and generated into typed client/server code for all three services.

Alternative considered: Use REST/JSON between services, since it's the more common default for web APIs.

Tradeoff: The shared protobuf schema and generated stubs reduce contract drift and make incompatible message shapes explicit earlier, and HTTP/2 gives the streaming call somewhere Python doesn't have to build itself — at the cost of codegen tooling and losing the "just open it in a browser or curl it" debuggability plain JSON has.

Why hand-roll the circuit breaker?

I implemented the breaker directly, in plain Python, because I wanted to understand the state transitions myself and see exactly how it interacts with retries, rather than hide that behavior behind a library. It's scoped to the Search call only — not User validation, not the streaming price feed — since Search is the dependency deliberately made flaky for testing (the exact thresholds and states are covered above).

Alternative considered: Use an existing circuit-breaker library, and/or apply it uniformly to every outbound call.

Tradeoff: A custom implementation is easier to inspect but lacks the maturity, edge-case coverage, and configurability of a production resilience library.

Why retry wraps the circuit breaker, not the other way around

I structured the retry loop to call through the circuit breaker fresh on every attempt, rather than check it once up front, because I wanted a request's own retries to be able to trip the breaker mid-sequence — a scenario the walkthrough above shows directly.

Alternative considered: Check the circuit breaker once up front, and only enter the retry loop at all if it's currently closed.

Tradeoff: Checking once up front would fail faster once the breaker is already known to be open, at the cost of a request no longer being able to be the one whose own retries trip it.

Why mTLS only on the two hops the Orchestrator initiates

I scoped mTLS to the two hops the Orchestrator itself initiates (User, Search) because backend-to-backend trust was specifically what this project set out to explore — the invariants above call out the client-facing edge as the known gap rather than smoothing over it.

Alternative considered: Also put the Orchestrator's own inbound port behind mTLS, or at minimum plain TLS.

Tradeoff: In a real deployment, the client-facing edge would typically sit behind its own protection — a load balancer or API gateway — which this project doesn't include; securing every hop would close this gap entirely, it just wasn't what got built here.

User validation is a stub, on purpose

"Is this user allowed to book" is answered by a single string comparison against one hardcoded user_id — no database, no real user store, no actual authentication behind it.

Alternative considered: Wire up a real user lookup, even a minimal one.

Tradeoff: Keeping User validation trivial kept the project's actual focus — circuit breakers, retries, mTLS, tracing, metrics — from getting diluted by an unrelated auth system, at the cost of a demo that's less realistic on that one dimension.

Docker Compose for the observability stack, not the services

Prometheus, Grafana, and Jaeger run under one docker compose up. The three gRPC services run as local Python processes.

Alternative considered: Containerize everything, services included.

Tradeoff: The observability stack is static infrastructure that doesn't change while developing — Compose removes real setup friction there for free. Leaving the services as local processes keeps the edit-and-restart loop fast during active development, at the cost of not yet proving the whole system also behaves correctly containerized — that's listed as future work below.

Code that proves the claim

The circuit breaker's core check

The heart of the state machine — once open, every call is rejected instantly until the cooldown passes, at which point exactly one call gets through to test the waters.

if self.state == "OPEN":
    if time.time() - self.last_failure_time > self.recovery_time:
        self.state = "HALF_OPEN"
    else:
        raise Exception("Circuit is OPEN. Skipping call.")

What's actually a stub vs. what's real

Two honest limitations worth seeing in the actual code — the Orchestrator secures its outbound calls but not its own inbound port, and "user validation" is a placeholder, not real logic.

# orchestrator_server.py — the orchestrator's own inbound port has no TLS:
server.add_insecure_port('[::]:50053')

# user_server.py — "validation" is a single hardcoded comparison:
if user_id == "123":

Open problems I want to test next

What this project taught me

Retries are not automatically safe. Without a bound and a growing backoff, retrying a struggling dependency just adds load to something that's already failing — the cap at 3 attempts and the doubling wait weren't incidental details, they were the difference between protecting Search and making its problem worse.

A circuit breaker protects the caller as much as the dependency. The clearest example here is the request that arrives right after the breaker trips: it fails in about the same time as a request that goes all the way to Search, because it still runs its retry waits — the breaker skipped the network call, but the caller still paid for the decision to check three times.

Resilience mechanisms interact in ways that aren't visible from reading either one in isolation. Retry wrapping the breaker instead of the other way around is a one-line structural choice, but it changes who can trip the breaker and what a caller experiences once it's open — that only became obvious by tracing one request through both mechanisms together, not by reading the retry loop or the breaker separately.

"We use mTLS between services" is a claim that only means something once every hop has actually been checked — this project's two inter-service edges are genuinely protected, and its client-facing edge genuinely isn't, and saying both of those things plainly is more useful than a diagram that implies uniform coverage. Breaking the system on purpose, and watching what Jaeger and Grafana actually showed while it happened, taught me more about this system's real behavior than any amount of testing only the happy path did.

View source on GitHub