Internal SDKs for inter-service calls
When a company runs a dozen services, each one calling another over HTTP grows the same 200 lines of client code: base URL, auth, retries, timeouts, error mapping. The internal SDK is the decision to write that once and ship it as a package.
What the SDK actually owns
The failure mode without one is not “duplicated code” - it is inconsistent failure behaviour. Service A retries a 500 five times, service B not at all, service C retries a 400 forever. None of that is visible until an incident.
An internal SDK owns the policy:
| Concern | What the SDK fixes |
|---|---|
| Auth | Token fetch, refresh, and rotation |
| Retries | What is retryable, and the backoff |
| Timeouts | A default nobody forgets to set |
| Errors | HTTP status to typed exception |
| Tracing | Context propagation headers |
The calling service imports a method, not a URL.
# Without: every caller re-derives the contract.
r = await client.get(
f"{settings.billing_url}/v1/invoices/{id}",
headers={"Authorization": f"Bearer {tok}"},
timeout=5.0,
)
r.raise_for_status()
return Invoice(**r.json())
# With: the contract is the signature.
return await billing.invoices.get(id)Generated or hand-written
Two approaches, and the choice sets your maintenance cost.
Generated from a spec. The service publishes OpenAPI or protobuf; CI generates a typed client on every release. The contract cannot drift from the implementation because it is derived from it.
Hand-written. A human writes and versions the client. It reads better and can expose a nicer API than the wire format, but it is a second thing to update, and it will lag.
Generate the transport, hand-write the ergonomics on top. gRPC gives you this split for free - the stub is generated, and you wrap it.
class BillingClient:
def __init__(self, stub: BillingStub):
self._stub = stub # generated
async def get_invoice(self, id: str) -> Invoice:
try:
return await self._stub.GetInvoice(
GetInvoiceRequest(id=id)
)
except AioRpcError as e:
raise _map(e) from e # hand-writtenThe coupling problem
This is the objection an interviewer is looking for. A shared client library is a shared dependency, and it can undo the independence microservices exist to buy.
Three specific ways it bites:
- Lockstep upgrades. If the SDK bundles the server’s models and the server changes them, every consumer must upgrade to stay compatible. That is a distributed monolith wearing a package manifest.
- Transitive conflicts. The SDK pins
httpx>=0.27; a consumer pinshttpx<0.27. Now a billing change blocks an unrelated deploy. - Logic leaking in. Someone adds a “convenience” method that computes business rules client-side. Now the rule has two implementations.
Keeping it thin
- Version the SDK independently of the service, and support the last two major API versions so consumers upgrade on their own schedule.
- Depend on as little as possible. An SDK that pulls in a web framework is a liability. Prefer a narrow HTTP client and stdlib types.
- No business logic. Serialise, call, map errors, return. If a method makes a decision, it belongs on the server.
- Never share the server’s ORM models. Ship a separate DTO. Sharing the model means a database migration ripples into every consumer.
The alternative worth naming
A service mesh moves retries, timeouts, mTLS and tracing into a sidecar, so the SDK shrinks to types and serialisation. That is the right split when services are polyglot: you cannot ship a Python SDK to a Go team, but Envoy does not care what language it is proxying.
Use a mesh for transport policy and an SDK for typed ergonomics. See Service Mesh.
Related
Interview angle 5
- “How do your services call each other?” - through a versioned internal SDK rather than raw HTTP in each caller, so auth, retries, timeouts, error mapping and trace propagation have one implementation and one behaviour. Callers get a typed method, not a URL.
- “What is the risk of a shared client library?” - it recreates the coupling microservices were meant to remove. If the SDK bundles the server’s models, a server change forces a lockstep upgrade across every consumer, and its transitive pins can block unrelated deploys. That is a distributed monolith.
- “How do you keep it from becoming that?” - version the SDK separately from the service, support the last two API versions, keep dependencies minimal, ship DTOs rather than the server’s ORM models, and allow no business logic in it. If a method decides something, it belongs on the server.
- “Generate it or write it?” - generate the transport from OpenAPI or protobuf so the contract cannot drift from the implementation, then hand-write a thin ergonomic layer over it for error mapping and naming.
- “When would you not use one?” - polyglot services, where you would need an SDK per language. Then push retries, timeouts, mTLS and tracing into a service mesh sidecar and keep the client to types and serialisation.