0) Problem Restatement
Snowflake asked: a company runs many application servers that call internal REST services. Today, every caller hand-writes the same boilerplate for each call: build the URL and HTTP request, add auth headers, serialize JSON, set timeouts, retry on failures, parse responses, map errors, and log. This leads to copy-paste bugs and inconsistent behavior (some callers never retry, others retry forever). Design a reusable service-client SDK that removes the boilerplate and makes calls consistent.
1) Goals
- A caller writes
orders = client.orders.list(user_id=42)and gets typed results. - Consistent timeouts, retries, auth, tracing, metrics and error handling everywhere.
- Easy to add new services and endpoints.
- Configurable per service (timeouts, base URL), and safe defaults.
2) Design
Architecture Diagram
flowchart LR
APP["Application code"] --> TC["Typed client - orders.list(), users.get()"]
TC --> PIPE["Middleware pipeline"]
PIPE --> AUTH["Auth - service token"]
AUTH --> TR["Tracing + metrics"]
TR --> RET["Retry + backoff (idempotent only)"]
RET --> CB["Circuit breaker + timeout"]
CB --> HTTP["HTTP transport - pooled connections"]
HTTP --> SVC["Internal REST service"]
SVC --> ERR["Response parsing + error mapping"]
ERR --> TC2.1 Typed clients (generated)
- Each service publishes an OpenAPI spec. A code generator produces typed client classes and models (in Java, Python, Go...), so method names, parameters and response types come from the spec. When the spec changes, regenerate.
- Generated code handles paths, query and body serialization, and JSON parsing into typed objects.
2.2 Middleware pipeline (hand-written once, shared)
Like interceptors or filters, each concern is one small, testable piece:
- Auth: attach a short-lived service token (fetched and refreshed automatically).
- Tracing: propagate trace IDs, and create a span per call.
- Metrics: latency, status codes, retries per service and endpoint.
- Retries: only for idempotent methods (GET, PUT, DELETE) or calls with an idempotency key. Exponential backoff with jitter, a max attempts count, and a retry budget so outages aren't amplified.
- Timeouts: per-call defaults from config, and deadline propagation from the incoming request.
- Circuit breaker: fail fast when a service is unhealthy.
- Logging: structured, with secrets redacted.
2.3 Consistent error model
Map HTTP and network errors into a small set of exception types: NotFound, Unauthorized, RateLimited(retry_after), ValidationError(details), ServiceUnavailable, Timeout. Callers handle meaning, not raw status codes.
3) Example
client = ServiceClient.for_service("orders") # config: base_url, timeouts from registry
try:
page = client.orders.list(user_id=42, limit=20) # typed request, typed response
for o in page.items:
print(o.id, o.total)
except RateLimited as e:
schedule_retry(after=e.retry_after)
except NotFound:
...
4) Rollout and Evolution
- Config registry: base URLs, timeouts and retry policies per service, changeable without code changes.
- Versioning: services keep backward-compatible changes within
/v1. The SDK is semantically versioned, and deprecated methods log warnings. - Adoption: start with the most-used services, provide migration guides, and lint rules that flag raw HTTP calls to internal services.
- Testing: generated clients have mock servers (from the spec) for callers' unit tests.
5) Wrap-Up
Generate typed clients from each service's OpenAPI spec, and run every call through one shared middleware pipeline: auth tokens, tracing and metrics, retries only for idempotent calls with backoff and a budget, timeouts with deadline propagation, circuit breakers and redacted logging. Map responses into a consistent error model, drive per-service settings from a config registry, and roll it out with versioning, mocks and lint rules that discourage hand-written HTTP calls.