Capability honesty¶
Five SDKs, one config — something has to give. Most portability layers give up quietly: the option you set does nothing on backend X, and you find out during an incident. clientwright's alternative is a capability model with three moving parts: a declaration per adapter, a report per build, and a policy that decides how loud a mismatch is.
The declaration¶
Every adapter ships a frozen AdapterCapabilities record — importable without
the SDK installed:
import clientwright
caps = clientwright.capabilities_matrix()["aiohttp"]
print(caps.seam) # 'middleware' — where the engine is installed
print(caps.support) # Capability -> native | emulated | degraded | absent
print(caps.emits) # which FailureKinds this adapter can actually produce
print(caps.collapses) # finer kinds that fold into coarser ones here
Four support levels, honestly graded:
| Level | Meaning |
|---|---|
native |
the SDK expresses it itself |
emulated |
the engine provides it on top (e.g. per-host limits via a semaphore) |
degraded |
works, with a stated weaker guarantee (e.g. soft deadlines on sync) |
absent |
not available; config asking for it is dropped |
emits and collapses extend the honesty to failure taxonomy: an adapter that
cannot distinguish DNS errors from connect errors says so, instead of inventing a
distinction it cannot observe.
The report¶
Building a client evaluates your config against the declaration and attaches the verdict to the handle:
handle = clientwright.build_handle("requests", config)
handle.report.applied_natively # what the SDK expressed itself
handle.report.emulated # what the engine added on top
handle.report.dropped # {Capability: reason} — could not be honored
handle.report.dead_retryable_kinds # retry triggers this adapter can never emit
handle.report.native_overrides # accepted native passthrough, per slot
dead_retryable_kinds deserves a sentence: if your retry policy waits for
dns_error but the adapter never emits it, that trigger is dead — configured,
believed in, and impossible. The report says so at build time instead of letting
you discover it from a graph that never moves.
The policy¶
on_unsupported turns the report into behavior:
from clientwright import ClientConfig, UnsupportedPolicy
ClientConfig(service_name="x", on_unsupported=UnsupportedPolicy.STRICT) # raise at build
ClientConfig(service_name="x", on_unsupported=UnsupportedPolicy.WARN) # log, continue (default)
ClientConfig(service_name="x", on_unsupported=UnsupportedPolicy.IGNORE) # silence
The house recommendation: STRICT in production configs. A service that
starts is a service whose config is fully in effect; a dropped knob becomes a
failed deploy instead of a false belief. WARN is the forgiving default for
exploration; IGNORE is for the rare config that is deliberately shared across
adapters with known, accepted gaps.
Reading the matrix before you commit¶
Because declarations import without SDKs, you can diff adapters ahead of a migration:
matrix = clientwright.capabilities_matrix()
for name, caps in matrix.items():
print(name, caps.support_of(clientwright.Capability.HTTP2))
That, plus the adapter pages, is the whole decision input for "can we move this service from requests to httpx" — no folklore required.