SDK and Client Library Design Questions
Packaging an API for developer consumption via SDKs and client libraries: ergonomic method surfaces, auth handling, pagination helpers, retry/backoff built in, error mapping, and versioning of generated or hand-written clients. Covers cloud/mobile SDK design, code generation from contracts, and keeping SDKs consistent across languages.
You operate a public API with many SDK consumers. Propose an SDK compatibility and release strategy that ensures minor server changes do not break clients. Include semantic versioning, deprecation tooling, backwards-compatible API layers, automatic integration tests between SDKs and staging endpoints, and documentation generation.
Sample Answer
Requirements & goals:
- Allow server minor/patch changes without breaking existing SDK consumers
- Give clear upgrade path for clients
- Automate verification and docs
Strategy:
- Semantic versioning policy
- Server: MAJOR.MINOR.PATCH. MINOR adds backward-compatible features; MAJOR for breaking changes.
- SDKs: mirror server semver and add a compatibility matrix in docs (e.g., SDK v2.x supports Server 2.(x-1) through 2.(x+1) depending on guarantees).
- Enforce in CI: reject PRs that change public contract without bumping MAJOR.
- Backwards-compatible API layer
- Keep stable API surface; add new fields as optional, new endpoints versioned (/v2/...) only for breaking changes.
- Use feature flags and content negotiation to roll out changes gradually.
- Provide a compatibility adapter service that maps legacy payloads to new schemas for a transitional period.
- Deprecation tooling & policy
- Annotate deprecated endpoints/fields with metadata (deprecated_since, removal_version, migration_guide).
- Emit Deprecation HTTP headers (Deprecation, Sunset) and add warnings in responses.
- Automatic deprecation dashboard: collect usage metrics to decide when to remove.
- Automated integration tests
- For every SDK release pipeline, run integration test suite against a staging environment that exercises:
- Happy paths, error cases, schema evolution (missing/new fields), and backward compatibility scenarios.
- Matrix test across SDK versions and staged server versions (e.g., run SDK v1., v2. against staging server builds).
- Gate merges: integration tests must pass before releasing server or SDK.
- CI/CD & release flow
- Server PRs that alter public contract trigger a compatibility-checker job that compares OpenAPI specs and flags breaking diffs.
- On server minor/patch release: run compatibility tests; if only additive, release automatically.
- SDKs are released via automated pipelines that bump patch/minor per changes; include changelog generated from commits.
- Documentation & generation
- Source OpenAPI/GraphQL schema as single source of truth.
- Auto-generate SDK docs, API reference, and migration guides from schema and annotated comments.
- Publish per-version docs and a compatibility matrix; include code samples for previous versions.
- Provide migration CLI that scans client code for deprecated usage patterns (optional).
Example tools & practices
- Use OpenAPI + Spectral for linting, SwaggerDiff for compatibility checks.
- Use CI (GitHub Actions/GitLab CI) to run matrix tests in ephemeral staging via docker-compose / k8s test namespace.
- Use semantic-release for SDKs to automate versioning and changelogs.
Why this works
- Semantic versioning + compatibility checker prevents accidental breaking changes.
- Deprecation metadata + headers give clients time to migrate.
- Backwards-compatible adapter + staged integration tests ensure real SDKs won't break undetected.
- Auto-generated docs keep guidance accurate and reduce friction for consumers.
Design an approach for library code to emit logs without overwhelming consumers: how would you structure log levels, allow injection of a logger, and keep default behavior quiet while enabling verbose logs for debugging? Provide example API surface for the library logger integration.
Sample Answer
Approach (summary):
- Provide a small logging abstraction (LogLevel enum + Logger interface) used internally.
- Default to a no-op or error-only logger so library is quiet by default.
- Allow consumers to inject their logger (any object matching interface) or configure via factory/helper.
- Respect common log levels (ERROR, WARN, INFO, DEBUG, TRACE) and include structured context.
- Allow opt-in verbose via env/config or a one-time set_logger call.
API surface (Python-style):
from enum import IntEnum
from typing import Protocol, Any, Dict
class LogLevel(IntEnum):
ERROR = 40
WARN = 30
INFO = 20
DEBUG = 10
TRACE = 5
class Logger(Protocol):
level: LogLevel
def log(self, level: LogLevel, msg: str, **meta: Any) -> None: ...
def error(self, msg: str, **meta: Any) -> None: ...
def warn(self, msg: str, **meta: Any) -> None: ...
def info(self, msg: str, **meta: Any) -> None: ...
def debug(self, msg: str, **meta: Any) -> None: ...
Library globals and helpers:
# default no-op logger: quiet unless ERROR
class _NoopLogger:
level = LogLevel.ERROR
def log(self, level, msg, **meta):
if level >= self.level: print(msg) # or route to stderr for ERROR
def error(self, msg, **meta): self.log(LogLevel.ERROR, msg, **meta)
def warn(self, msg, **meta): self.log(LogLevel.WARN, msg, **meta)
def info(self, msg, **meta): self.log(LogLevel.INFO, msg, **meta)
def debug(self, msg, **meta): self.log(LogLevel.DEBUG, msg, **meta)
_lib_logger: Logger = _NoopLogger()
def set_logger(logger: Logger) -> None:
global _lib_logger
_lib_logger = logger
def get_logger() -> Logger:
return _lib_logger
Usage inside library:
get_logger().debug("cache-miss", key=key)
get_logger().info("connected", host=host, port=port)
Consumer integration examples:
- Inject standard logging (Python): wrap logging.Logger to implement Protocol; set level to DEBUG when needed.
- For JavaScript/Node: same pattern — default silent logger, accept any object with .log(level,msg,meta) or adapters for console/winston/pino.
Design choices & reasoning:
- Use numeric levels so comparison is cheap and layerable.
- No-op default avoids overwhelming apps and leaking internal details.
- Injection keeps library framework-agnostic and testable.
- Structured meta supports observability systems.
- Allow one-time set_logger and optional per-call correlation/context for advanced use.
Best practices:
- Document that consumers should provide thread-safe loggers.
- Avoid logging secrets; allow sanitization hooks.
- Provide small adapters for popular loggers to reduce friction.
You need to design first-party SDKs for your public API in Python, JavaScript, and Java. Describe SDK responsibilities (authentication helpers, retries, pagination helpers, typed models), release and versioning strategy (semantic versioning, deprecation windows), error mapping, and how to automate SDK generation from API contracts while preserving idiomatic language patterns.
Sample Answer
Responsibilities (what each SDK must do)
- Authentication helpers: standardized builders for API keys, OAuth2 flows, token refresh hooks, and secure storage tips. Expose simple entry points (e.g., Client(apiKey=...), OAuthClient.get_token()).
- Transport + retries: configurable HTTP client with sane defaults (exponential backoff with jitter, idempotency detection, max attempts, and per-endpoint retry overrides).
- Pagination helpers: iterators/generators that yield items transparently, with cursor/page auto-advance and size controls.
- Typed models: language-native models (dataclasses/pydantic in Python, interfaces/classes in TypeScript, POJOs with builders in Java) generated from contract but post-processed for idiomatic names and nullability.
- Convenience utilities: request builders, batch helpers, logging hooks, metrics integration, and test fixtures.
- Documentation & examples: README, quickstart, and snippet-per-language.
Release & versioning strategy
- Semantic Versioning (MAJOR.MINOR.PATCH). Major for breaking API/behavioral changes, minor for backward-compatible features, patch for fixes.
- Deprecation policy: minimum 3-6 month deprecation window for public-breaking changes; mark deprecated methods in docs and emit runtime warnings. Maintain a CHANGELOG.md and "supported versions" matrix.
- Release automation: CI/CD publishes artifacts to PyPI/npm/Maven Central on tagged commits, plus signed release notes and migration guides.
- Compatibility guarantees: minor upgrades must not change public method signatures/behaviors.
Error mapping
- Translate HTTP errors into typed exceptions: ValidationError (4xx-422), AuthenticationError (401/403), NotFoundError (404), RateLimitError (429 with retry-after), ServerError (5xx). Each exception carries: HTTP code, error code, message, details, retriable boolean, and raw response.
- Retry policy uses error classification: only retry on network timeouts, 5xx, and explicit retriable error codes. Expose hooks so users can customize mapping and backoff.
- Surface structured error objects to callers and make it easy to serialize/inspect for logging.
Automate generation from API contracts while preserving idiomatic patterns
- Source of truth: maintain OpenAPI (or protobuf/gRPC) spec.
- Two-stage generation:
- Machine-generated artifacts: generate models, DTOs, and low-level request/response bindings using codegen tools (OpenAPI Generator / protoc). Keep generation deterministic and idempotent.
- Language-specific post-processing layer: run code-style transformers:
- Python: convert generated models to dataclasses/pydantic, rename snake_case, add typed iterators, and inject context managers.
- JavaScript/TypeScript: generate TS types and hand-crafted thin wrappers exposing promise/async iterators and ergonomic defaults.
- Java: generate immutable POJOs with builders, validate nullability annotations, and provide fluent clients.
- Use template hooks & partials: keep core generated code isolated; place custom handwritten idiomatic code in separate files that wrap or extend generated classes so regeneration doesn't clobber manual edits.
- Linting and formatting step: run black/prettier/google-java-format; run static analyzers to ensure idiomatic patterns.
- Tests and smoke tests: auto-generate unit tests for serialization/deserialization and integration tests against a mock server; include examples per language.
- Developer ergonomics: expose contribution templates and a "regenerate" script that runs generation + post-processors; review diffs for idiomatic changes in PRs.
Trade-offs & best practices
- Prefer small handwritten wrappers over fully hand-coded SDK for consistency and speed.
- Keep low-level bindings stable and generate only models + HTTP wiring; expose high-level helpers as maintainable, tested code.
- Make customization points explicit (hooks, middleware) so consumers adapt behavior without forking SDK.
- Monitor telemetry and usage to prioritize which helpers to keep idiomatic versus purely generated.
Implement a Python module that batches outbound HTTP API calls up to N requests per second and maps responses back to original callers. The module should support per-call timeouts, retries, and an asynchronous interface (asyncio). Provide clear pseudocode showing batching, concurrency, and error handling.
Sample Answer
To solve this, use an asyncio-based batching worker that collects requests into a batch window (1 second or when batch size reached), issues a single upstream call (or parallel calls constrained by rate N/s), and maps responses back via Futures. Support per-call timeout/retries by tracking attempts and using asyncio.wait_for.
import asyncio
from typing import Any, Dict, List, Tuple
import time
class BatchClient:
def __init__(self, n_per_sec: int, batch_size: int = 50, max_retries: int = 2):
self.n_per_sec = n_per_sec
self.batch_size = batch_size
self.max_retries = max_retries
self._queue = asyncio.Queue()
self._tokens = n_per_sec
self._last_refill = time.monotonic()
self._worker = asyncio.create_task(self._batch_worker())
async def call(self, payload: Dict, timeout: float = 2.0) -> Any:
fut = asyncio.get_running_loop().create_future()
await self._queue.put((payload, fut, timeout, 0)) # attempt=0
return await asyncio.wait_for(fut, timeout=timeout + 0.1) # allow mapping time
async def _refill_tokens(self):
now = time.monotonic()
elapsed = now - self._last_refill
if elapsed >= 1.0:
self._tokens = self.n_per_sec
self._last_refill = now
async def _batch_worker(self):
while True:
await self._refill_tokens()
batch = []
# gather up to batch_size or until queue empty or tokens exhausted
while len(batch) < self.batch_size and self._tokens > 0:
try:
item = self._queue.get_nowait()
except asyncio.QueueEmpty:
break
batch.append(item)
if not batch:
# wait for first item or token refill
item = await self._queue.get()
batch.append(item)
# respect rate limit
to_send = min(len(batch), self._tokens)
send_items = batch[:to_send]
remaining = batch[to_send:]
for r in remaining:
await self._queue.put(r) # put back unused
self._tokens -= to_send
# build batched payload
batched_payload = [p for (p, _, _, _) in send_items]
# send upstream with retries per item mapping
try:
responses = await self._send_batched(batched_payload)
# map responses to futures (assumes 1:1 order)
for (_, fut, _, _), resp in zip(send_items, responses):
if not fut.done():
fut.set_result(resp)
except Exception as exc:
# on failure, schedule retries per item
for payload, fut, timeout, attempt in send_items:
if attempt < self.max_retries:
await self._queue.put((payload, fut, timeout, attempt + 1))
else:
if not fut.done():
fut.set_exception(exc)
await asyncio.sleep(0) # yield
async def _send_batched(self, batched_payload: List[Dict]) -> List[Any]:
# PSEUDOCODE: replace with real HTTP client, e.g., aiohttp
# Example: await aiohttp.post(..., json=batched_payload)
await asyncio.sleep(0.05) # simulate network
return [{"status": "ok", "data": p} for p in batched_payload]
Key points:
- Uses asyncio.Queue to decouple callers from sender.
- Rate limiting via token refill per second; batch triggers when queue has items.
- Per-call Future maps responses back; per-call timeout handled with asyncio.wait_for.
- Retries: failed batch re-enqueues items with attempt count.
Complexity: O(m) per batch where m=batch_size; memory O(Q) queued requests.
Edge cases: upstream returns partial failures, reordering, long per-call timeouts — handle by including request IDs and matching responses. Alternatives: use leaky-bucket or token-bucket libraries, or shard workers for higher throughput.
Propose a strategy for automatic SDK migration when an API introduces a breaking change. Discuss how to generate migration patches, notify consumers, roll out compatibility shims in SDKs, and measure adoption of automated migrations across multiple languages and pinned dependency versions.
Sample Answer
Requirements & goals:
- Minimize consumer breakage when an API introduces a breaking change.
- Automate safe migration where possible, notify & assist developers, support multiple languages & pinned deps, and measure uptake.
High-level strategy (steps):
- Detect & model breaking change
- Record change metadata: affected endpoints, signature/types, behavior, deprecation timeline, semantic-impact level.
- Produce a machine-readable change spec (OpenAPI diff + migration hints).
- Generate migration patches
- Use code-mod templates per language driven by the change spec.
- For simple renames/type changes produce AST-based automated transforms (e.g., jscodeshift for JS, libCST for Python, refaster for Java).
- For complex logic produce suggested patch + test scaffolding and human-review PRs against consumer repos (if allowed) or sample snippet patches.
- Notify consumers
- Multi-channel: in-SDK warnings (compile/runtime), emails to registered owners, dashboard alerts, changelog & migration guide pages, and generated pull requests with CI.
- Include automatic code snippets and runnable tests demonstrating the migration.
- Roll out compatibility shims in SDKs
- Implement side-by-side shim layer: keep old API surface forwarding to new implementation where possible; emit deprecation warnings.
- Use feature flags to flip behavior server-side and in SDKs for staged rollout.
- Release shimbed SDKs per semantic-release, with clear minor/patch bumps for non-breaking shim and major bump when removing old behavior.
- Handle pinned dependency versions
- Offer backport shim releases for older major versions (patch releases) with the shim logic; tag compatibility matrix in release notes.
- If pinned deps block automation, provide forked branches and PRs to bump deps with automated tests.
- Measure adoption
- Instrument SDKs to optionally (privacy-safe) report migration telemetry: patch applied, SDK version, API version, success/failure, anonymized stack trace on error. Respect opt-in & GDPR.
- Track PR merges for generated patches, installs of shimmed SDK versions, and server-side feature flag toggles.
- KPIs: % of clients migrated, time-to-migration median, number of automated vs manual fixes, rollback rate, error rates post-migration.
Operational & safety considerations:
- Gate auto-PRs behind CI checks and sandbox tests; require human approval for behavior-changing patches.
- Maintain per-language experts and surgeon-reviewed templates for complex cases.
- Communicate timelines and sunset dates prominently.
Example concrete flow:
- API v1 endpoint renamed -> OpenAPI diff created -> JS/Python code-mods generated -> automatic PRs opened on repos with passing unit tests; SDK patch released with shim forwarding old call and emitting console warning; telemetry shows 60% auto-PR merges within 2 weeks; remaining users receive targeted emails and backport SDKs.
Trade-offs:
- High automation reduces user effort but increases risk—mitigate with tests, canaries, opt-in telemetry, and staged rollout.
- Supporting pinned/old deps increases maintenance cost; mitigate with backport policies and clear deprecation windows.
This approach balances automation, safety, and developer experience across languages and pinned dependency scenarios.
Unlock Full Question Bank
Get access to all 6 SDK and Client Library Design interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.