Connector Layer
Audience: Engineers implementing or swapping vendor adapters.
What you will accomplish: Understand the ports-and-adapters architecture — how to implement a new adapter, test it with contract tests, and register it without touching core logic.
Prerequisites: Read Architecture and Core Services first.
Estimated time: ~10 minutes.
The connector layer implements ports-and-adapters architecture. Workflows import only Protocol definitions (the ports); each vendor system has an adapter that maps between the domain model and the vendor's API.
What is a connector?
A connector is the bridge between the server's domain model and an external vendor API (Clio, Salesforce, Microsoft Graph, etc.). The system uses ports and adapters: a port is a Python Protocol (interface) that defines what operations are available; an adapter is the concrete implementation that talks to a specific vendor.
Workflows import only the port. Swapping vendors means implementing one adapter file — zero workflow code changes.
Port definitions
class CaseConnector(Protocol):
async def get_matter(self, id: str) -> Matter: ...
async def create_matter(self, data: MatterDraft) -> Matter: ...
async def list_deadlines(self, matter_id: str) -> list[Deadline]: ...
class CRMConnector(Protocol):
async def upsert_contact(self, c: Contact) -> Contact: ...
async def find_contact(self, q: str) -> list[Contact]: ...
class EmailConnector(Protocol):
async def create_draft(self, c: Communication) -> str: ...
async def send(self, draft_id: str, idem_key: str) -> str: ...
class DocStoreConnector(Protocol):
async def put(self, doc: Document, content: bytes) -> Document: ...
async def get(self, id: str) -> tuple[Document, bytes]: ...
async def move(self, id: str, folder: str, acl: ACL) -> Document: ...The ACL value type is defined with precise typing:
class ACL(BaseModel):
principals: list[str] # user IDs or role names
permission: Literal["read", "write", "none"] # access level
external: bool # whether this is an external shareThe external field is critical for the privilege gate: a document with privileged=True can never be moved with an ACL where external=True.
Workflows depend on the port, never the adapter. Swapping Clio → MyCase touches one file.
Adapter requirements
Every connector adapter must:
- Map vendor payloads ↔ domain model — no vendor JSON reaches the core
- Use
httpx+tenacity— exponential backoff, jitter, honour rate limits - Be idempotent on writes — pass/forward idempotency keys
- Surface typed
ConnectorError— auth, rate-limit, not-found, transient, fatal - Ship contract tests — against recorded VCR fixtures (
respx/vcr) - Ship
CONNECTOR.md— auth model, scopes, endpoints, quirks, webhook events
Retry policy defaults
All outbound connector calls use httpx + tenacity with these defaults:
| Parameter | Value |
|---|---|
max_attempts | 5 |
base_delay | 0.5 seconds |
max_delay | 30 seconds |
total_deadline | 120 seconds |
jitter | full jitter (random uniform) |
RateLimitError delays respect the vendor's Retry-After header when present, overriding the calculated backoff.
Connector registry
Connectors are registered at startup through a ConnectorRegistry:
from cam.connectors import ConnectorRegistry, ReferenceCaseConnector, ReferenceCRMConnector
registry = ConnectorRegistry()
registry.register("case", ReferenceCaseConnector())
registry.register("crm", ReferenceCRMConnector())The ServiceContainer receives the registry, and workflows access connectors by category name (services.connectors.case, services.connectors.crm, etc.) — never by adapter class name.
ConnectorError taxonomy
The orchestrator uses the error type to decide retry vs gate vs fail:
| Error | HTTP code | Retry? | Action |
|---|---|---|---|
AuthError | 401/403 | No | Park; alert |
RateLimitError | 429 | Yes (respect Retry-After) | Backoff retry |
NotFoundError | 404 | No | Park; human triage |
TransientError | 5xx, timeout | Yes (max 5) | Exponential backoff |
FatalError | 4xx (other) | No | Park; alert |
Reference adapters
All four connector categories ship reference adapters — in-memory implementations that satisfy the Protocol for testing and development:
| Adapter | Path | Purpose |
|---|---|---|
ReferenceCaseConnector | cam.connectors.case_reference | In-memory matter store |
ReferenceCRMConnector | cam.connectors.crm_reference | In-memory contact store |
ReferenceEmailConnector | cam.connectors.email_reference | In-memory draft/send log |
ReferenceDocStoreConnector | cam.connectors.docs_reference | In-memory document store |
These enable full-fidelity workflow testing without live vendor credentials or network calls.
v1 candidate adapters
| Category | Recommended v1 | Auth | Webhook format |
|---|---|---|---|
| Case management | Clio | OAuth 2.0 PKCE | X-Clio-Signature: sha256=… |
| CRM | Lawmatics | OAuth 2.0 | Lawmatics HMAC header |
| Microsoft Graph (365) | OAuth 2.0 delegated | Azure Event Grid subscription | |
| Document store | SharePoint / OneDrive | OAuth 2.0 (same token) | — |
These adapters are stubs awaiting firm sign-off on vendors (PRD §11 Q1). The reference adapters are production-tested and ready to swap.
Webhook ingestion
The FastAPI sidecar exposes /webhooks/{connector}. Each inbound event:
- Verified — HMAC signature or shared secret per provider
- Normalised — mapped to internal
Eventmodel - Deduplicated — provider event id → Redis (TTL = 7 days)
- Enqueued — passed to orchestrator as a workflow trigger
Verification
@router.post("/webhooks/{connector}")
async def receive_webhook(
connector: str,
request: Request,
x_hub_signature: str = Header(None),
):
body = await request.body()
verifier = get_verifier(connector)
if not verifier.verify(body, x_hub_signature):
raise HTTPException(401, "Invalid signature")
event = normalise(connector, body)
if await is_duplicate(event.id):
return {"status": "duplicate"}
await enqueue_trigger(event)
return {"status": "accepted"}Dedup TTL
Default: 7 days. Configurable via CAM_WEBHOOK_DEDUP_TTL_SECONDS.
Circuit breaker
Each connector has a ConnectorHealth circuit breaker:
CLOSED— normal operationOPEN— afterfailure_thresholderrors inwindow_seconds; all requests fail fastHALF_OPEN— aftercool_down_seconds; allows a probe request
For multi-worker deployments, circuit state is backed by Redis sorted sets (Phase 2).
Isolation guarantee
One connector failing does not cascade to others. Each connector has its own circuit breaker, connection pool, and health state. If the CRM adapter goes OPEN, the case management adapter continues operating normally.
Connection pooling
Per-connector httpx.AsyncClient with pool limits aligned to vendor rate limits:
_http_client = httpx.AsyncClient(
limits=httpx.Limits(
max_connections=50,
max_keepalive_connections=20,
keepalive_expiry=30.0,
),
timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=2.0),
)Testing connectors
Contract tests use respx to mock vendor HTTP APIs and verify the adapter maps correctly:
import respx
@respx.mock
def test_clio_get_matter():
route = respx.get("https://app.clio.com/api/v4/matters/123.json").mock(
return_value=httpx.Response(200, json={"data": {...}})
)
adapter = ClioAdapter(token="test")
matter = asyncio.run(adapter.get_matter("123"))
assert matter.reference == "REF-123"
assert route.calledCommon mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Importing a vendor adapter directly in workflow code | Locked to one vendor; swap requires touching N files | Import the Protocol port; inject adapter via ConnectorRegistry |
| Skipping contract tests for a new adapter | Subtle mapping bugs go undetected until production | Every adapter must ship respx/vcr contract tests |
Not handling RateLimitError separately | Rate-limited requests retry with wrong backoff | Respect Retry-After header; use tenacity wait strategy |
Forgetting to ship CONNECTOR.md | Other engineers can't understand auth model or quirks | Required for every adapter — documents auth, scopes, endpoints, webhook events |
Verification
After reading this page, verify your understanding:
- Name the four connector port Protocols and their key methods
- Explain what happens when a connector returns
TransientErrorvsAuthError - Show how the circuit breaker state machine works (CLOSED → OPEN → HALF_OPEN)
- Describe the webhook ingestion pipeline: verification → normalisation → dedup → enqueue
Next steps
- MCP Surface — Tools that invoke connectors
- Workflow Engine — How connectors are called from steps
- Core Services — What happens to connector outputs
Last updated: 2026-06-01