Client Method Design¶
The MedplumClient and AsyncMedplumClient are designed to be flexible and developer-friendly, catering to both traditional dictionary-based workflows and modern, type-safe Pydantic model-based workflows. This is achieved through the use of method overloading.
Universal Input: Dicts and Pydantic Models¶
For write operations, you can provide the resource data as either a standard Python dictionary or as an initialized Pydantic model instance. The client handles the conversion automatically.
create_resource(resource)¶
- Accepts:
dictor a Pydantic model instance. - Returns: A dictionary of the created resource.
from pymedplum.fhir import Patient
patient_dict = {
"resourceType": "Patient",
"name": [{"given": ["John"], "family": "Dict"}],
}
created_from_dict = client.create_resource(patient_dict)
patient_model = Patient(name=[{"given": ["Jane"], "family": "Model"}])
created_from_model = client.create_resource(patient_model)
update_resource(resource)¶
update_resource also accepts both formats, and by default attaches
an If-Match header from resource.meta.versionId for optimistic
concurrency control.
patient_model.active = False
client.update_resource(patient_model) # default: If-Match on
client.update_resource(patient_model, if_match=False) # opt out
client.update_resource(patient_model, if_match='W/"5"') # custom
Flexible Output: Dicts vs. Typed Models¶
For read operations, you can choose the format of the returned data. By default, you get a dictionary; pass as_fhir= to request a fully-typed Pydantic model for a better development experience.
read_resource(resource_type, id, as_fhir=...)¶
The as_fhir parameter controls the return type.
as_fhir=None(default): Returns adict.as_fhir=Patient(or any model class): Returns an instance of that Pydantic model.
from pymedplum.fhir import Patient
patient_dict = client.read_resource("Patient", "some-id")
print(patient_dict["name"][0]["family"])
patient_model = client.read_resource("Patient", "some-id", as_fhir=Patient)
print(patient_model.name[0].family)
Search Result Helpers¶
search_resources(..., return_bundle=...)¶
The search_resources method can return either a raw bundle dictionary or a helpful FHIRBundle wrapper object.
return_bundle=False(default): Returns adictrepresenting the FHIR Bundle.return_bundle=True: Returns an instance ofFHIRBundle, which provides helpful methods for iteration and type conversion.
from pymedplum.fhir import Patient
bundle = client.search_resources("Patient", {"family": "Smith"}, return_bundle=True)
for resource_dict in bundle:
print(resource_dict["id"])
patients = bundle.get_resources_typed(Patient)
for p in patients:
print(p.birth_date)
This design lets dictionary-based code and Pydantic-forward code coexist in the same codebase, at the granularity of a single call.
On-Behalf-Of (OBO)¶
OBO is a design invariant of the client, not an afterthought. The
client resolves the acting ProjectMembership in a fixed precedence
order for every request:
- Per-call kwarg —
method(..., on_behalf_of=...)wins if passed. An empty-string kwarg clears ambient state for that one call. - Context manager —
with client.on_behalf_of(...)sets an ambient value for its scope. Uses a per-instanceContextVar(with a UUID suffix in the name) so two clients in the same process never observe each other’s state — even when rapidly constructed and garbage-collected. - Client default —
default_on_behalf_of=on the constructor is the baseline for the client’s lifetime. - None — no
X-Medplum-On-Behalf-Ofheader is sent.
Async tasks sharing one client each see only their own context, per
standard ContextVar semantics. Threaded code must copy the context
explicitly (contextvars.copy_context) or use asyncio.to_thread;
plain ThreadPoolExecutor.submit does not propagate. See
On-Behalf-Of for worked examples.
Retry and throttling¶
The client retries transient failures with capped exponential backoff. Two invariants worth knowing:
- 429 handling parses
Retry-After(both seconds and HTTP-date forms) and respects the server-supplied delay, capped byMAX_RETRY_DELAY_SECONDSto avoid pathological pauses. - Token-refresh cooldown. If an OAuth refresh fails, the client
enters a cooldown window (default 1 second, configurable with
failed_refresh_cooldown=). Additional calls that would trigger a refresh during the window raiseTokenRefreshCooldownErrorwith aretry_afterattribute, rather than hammering the token endpoint. Refreshes are coordinated single-flight: one in-flight refresh serves every concurrent caller, with correct exception propagation.
Transport security¶
- HTTPS by default. A non-
https://base_urlraisesInsecureTransportErrorat construction unless the host is loopback (127.0.0.1,::1,localhost) orallow_insecure_http=Trueis passed (logs a WARNING). - No auto-follow redirects. The client is constructed with
follow_redirects=Falseand rejects caller-suppliedhttpx.Clientinstances that auto-follow. Pagination and async-job polling explicitly validate same-origin URLs; cross-origin follow-ups raiseUnsafeRedirectError.
Hooks¶
Two public extension points are exposed on the constructor:
before_request— receives a frozenPreparedRequestwith pre-redacted headers (bearer token and OBO header already stripped) and may return a modified copy. Return values are sanitized: anyAuthorizationheader is stripped, and cross-origin URL mutations are rejected and logged at WARNING.on_request_complete— fires once per logical SDK call (retries folded intoevent.attempts) with aRequestEventdataclass. Intended for PHI-access audit logging. The sync hook signature is accepted by both clients; an async hook is only valid onAsyncMedplumClient(the sync client raisesTypeErrorat construction). Hook exceptions are caught and logged at WARNING underpymedplum.hooks; they never fail the calling request.
See Audit Logging for the full
on_request_complete contract and worked examples.