Skip to main content

BackendV2Client

Sync + async client for the Synapse Backend API v2 (/v2/*).

The v2 client lives next to the existing v1 client (BackendClient) and is opt-in — both can be used in the same process. v2 differs from v1 in three load-bearing ways:

  • Cursor pagination with {next, previous, results} (no count field).
  • Auth header format: SYNAPSE-ACCESS-TOKEN: syn_<token> and a raw SYNAPSE-Tenant: <code> (no Token prefix by default).
  • Resource attributes: client.projects.list(...) instead of flat client.list_projects(...).

Targets synapse-backend release v2026.1.6 (API contract 2.0.0). Re-sync the schema with make sync-v2-all and bump synapse_sdk.clients.backend_v2.SYNAPSE_BACKEND_VERSION when the backend ships a new compatible release.

Overview

The v2 backend exposes 102 paths / 149 operations across 22 domains. The SDK implements 23 domains today — the 15 P0 group required for the primary CLI workflows plus 8 P1 domains added in Phase 3:

GroupResources
Auth / infraauth, tenants, tokens, schemas
Datadata_collections (+ nested groups), data_units, data_files
MLground_truth_datasets (+ nested versions), ground_truths, models, experiments
Workflowprojects (+ nested tags), tasks, assignments, reviews
Workspace (P1)members, groups (top-level, distinct from data-collection groups), validation_scripts, workshops
Execution (P1)jobs, job_logs, plugins, plugin_releases

The full endpoint catalog (and a v1↔v2 diff table — see v1 ↔ v2 endpoint diff) lives in INVENTORY.md.

Constructor

BackendV2Client(
base_url: str,
*,
access_token: str | None = None,
drf_token: str | None = None,
tenant: str | None = None,
tenant_token_prefix: bool = False,
timeout: dict[str, int] | None = None,
allow_insecure: bool = False,
)

Parameters

ParameterTypeRequiredDefaultDescription
base_urlstrYesBackend host. Do not include the /v2 prefix; resources add it themselves.
access_tokenstr | NoneNoNonesyn_* access token. Falls back to SYNAPSE_BACKEND_V2_ACCESS_TOKEN.
drf_tokenstr | NoneNoNoneDRF token from /users/login/. Falls back to SYNAPSE_BACKEND_V2_DRF_TOKEN.
tenantstr | NoneNoNoneTenant code. Falls back to SYNAPSE_BACKEND_V2_TENANT.
tenant_token_prefixboolNoFalseWhen True, sends SYNAPSE-Tenant: Token <code> for v1 compatibility.
timeoutdict[str, int] | NoneNo{'connect': 5, 'read': 15}Connect / read timeout in seconds.
allow_insecureboolNoFalseSuppress the warning when base_url uses plain HTTP.

AsyncBackendV2Client accepts the same arguments; its timeout accepts an httpx.Timeout instance instead of the dict shape.

Quick start

Synchronous

from synapse_sdk.clients.backend_v2 import BackendV2Client

client = BackendV2Client(
'https://api.test.synapse.sh',
access_token='syn_xxxxx',
tenant='acme',
)

# Cursor-paginated single page
page = client.projects.list(category='image', per_page=50)
for project in page.results:
print(project.id, project.title)
if page.next:
next_page = client.projects.list(cursor=page.next.split('cursor=')[-1])

# Stream every page lazily
for project in client.projects.list(category='image', per_page=50, list_all=True):
print(project.id, project.title)

Asynchronous

import asyncio
from synapse_sdk.clients.backend_v2 import AsyncBackendV2Client

async def main() -> None:
async with AsyncBackendV2Client(
'https://api.test.synapse.sh',
access_token='syn_xxxxx',
tenant='acme',
) as client:
page = await client.projects.list(per_page=50).first_page()
async for project in client.projects.list(per_page=50):
print(project.id, project.title)

asyncio.run(main())

Authentication

The v2 backend honours four auth schemes; the client composes them based on which arguments / env vars are present.

HeaderValueSource
SYNAPSE-ACCESS-TOKENsyn_<token>access_token= arg or SYNAPSE_BACKEND_V2_ACCESS_TOKEN env
AuthorizationToken <drf>drf_token= arg, SYNAPSE_BACKEND_V2_DRF_TOKEN env, or returned by client.auth.login()
SYNAPSE-Tenant<code> (default) or Token <code> (legacy)tenant= arg or SYNAPSE_BACKEND_V2_TENANT env; tenant_token_prefix=True opts into the legacy prefix
tenant_token_prefix default — v2-native

Since the OQ-1 live verification on api.test.synapse.sh (see Open items pending live verification), tenant_token_prefix defaults to False — the SDK sends the raw tenant code (SYNAPSE-Tenant: <code>). The dev backend accepts both formats, so v1-style deployments that still expect the Token <code> prefix can opt back via tenant_token_prefix=True:

client = BackendV2Client(
'https://api.example.com',
access_token='syn_...',
tenant='acme',
tenant_token_prefix=True, # ⇒ "SYNAPSE-Tenant: Token acme"
)

mask_token() helper provides log-safe representations of secrets:

from synapse_sdk.clients.backend_v2 import mask_token

mask_token('syn_12345678901234') # 'syn_***1234'
mask_token(None) # '<unset>'

Login → access-token bootstrap

client = BackendV2Client('https://api.test.synapse.sh', tenant='acme')

# 1. Exchange credentials for a DRF token (auto-stored on the client).
client.auth.login(email='[email protected]', password='...')

# 2. Issue a long-lived access token.
created = client.tokens.create({'description': 'sdk-cli'})
print(created.token) # 'syn_xxxxx' — only returned at creation time

# 3. Switch the client over to the new token.
client.set_access_token(created.token)
client.set_drf_token(None)

Pagination

The v2 backend uses DRF CursorPagination. After the transport unwraps the {data, meta} envelope (see Response envelope handling), list endpoints surface this CursorPage shape to resource modules:

{
"next": "<cursor-or-null>",
"previous": "<cursor-or-null>",
"results": [...]
}

There is no count field. The client exposes pagination via:

Call shapeReturns
client.projects.list(per_page=50)CursorPage[ProjectV2List] (single page)
client.projects.list(per_page=50, list_all=True)Iterator[ProjectV2List] (streams every page)
await client.projects.list(per_page=50).first_page() (async)CursorPage[ProjectV2List]
async for project in client.projects.list(per_page=50) (async)streams every page

Re-implement counters in user code if you depend on totals.

Response envelope handling

The v2 backend wraps every successful response in a {"data": ..., "meta": ...} envelope. The transport (BackendV2Client._request / AsyncBackendV2Client._request) calls unwrap_envelope on every successful payload so resource modules consume the legacy shape unchanged.

Backend payloadAfter unwrap
{"data": [...], "meta": {"pagination": {"next_cursor": "...", "previous_cursor": null, "per_page": 50}}}{"results": [...], "next": "...", "previous": null}
{"data": {"id": 1, ...}, "meta": {"request_id": "..."}}{"id": 1, ...}
{"data": null, "meta": {...}}null
Any payload missing either data or meta at the rootunchanged (forward-compat passthrough)

The unwrap is strictly conditional: it triggers only when both data and meta are present at the root. v1-style payloads, fixtures, and proxies that emit a non-enveloped shape pass through untouched.

meta.request_id is captured into a per-context slot before the unwrap discards meta, and is exposed via client.last_request_id and client.capture_request_ids() for log correlation.

Validation & Models

Each resource returns Pydantic models named after the OpenAPI schema:

SuffixUsed for
*V2ListList responses (slim payload)
*V2DetailRetrieve / create / update responses
*V2CreateRequestPOST body
Patched*V2CreateRequestPATCH body

response_model= / request_model= kwargs

The transport helpers (client._get, client._post, client._put, client._patch, client._delete and their async counterparts) accept two optional Pydantic kwargs that resource modules can lean on instead of calling Model.model_validate(...) manually:

# Before — manual validation in every method
return ProjectV2Detail.model_validate(self._get(f'/v2/projects/{pid}/'))

# After — kwarg-driven (resource methods become one-liners)
return self._get(f'/v2/projects/{pid}/', response_model=ProjectV2Detail)
# POST also accepts a Pydantic instance directly
return self._post(
'/v2/projects/',
data=ProjectV2CreateRequest(title='demo'), # sync uses `data=`
request_model=ProjectV2CreateRequest,
response_model=ProjectV2Detail,
)
# Async equivalent uses `json=` instead of `data=`.

Behaviour:

  • response_model=Model returns a fully constructed Model instance (not the raw dict).
  • request_model=Model accepts a Pydantic instance or a dict; both are serialised via model_dump(by_alias=True, exclude_none=True, mode='json'), matching the existing resources/_helpers.model_dump helper. Wire shape is identical whether callers pass a Pydantic instance or a plain dict.
  • A pydantic.ValidationError raised during response decoding is wrapped in synapse_sdk.exceptions.ValidationError. The original exception is preserved as __cause__.
  • A pydantic.ValidationError raised during request validation is propagated unchanged — caller-supplied data benefits from the full pydantic error detail for debugging (intentional asymmetry).
  • Omitting both kwargs leaves transport behaviour unchanged.
Phase 2 migration complete

All 60+ resource methods across the read and write domains were migrated to the response_model= / request_model= kwarg pattern in the SYN-6854 bundle. Caller signatures and wire shapes are unchanged — the conversion was purely internal — but pydantic.ValidationError no longer leaks out of any SDK call path. Catch synapse_sdk.exceptions.ValidationError and inspect __cause__ if you need the raw pydantic detail.

Security — response payloads never leak into ValidationError.detail

The mixin calls pydantic.ValidationError.errors(include_input=False) before wrapping, so each error entry strips the raw response body. Responses may carry secrets (e.g. the plaintext syn_* token returned by tokens.create()) and SDK consumers commonly forward ValidationError.detail to external logging sinks. Full-fidelity diagnostics remain available via exc.__cause__ (the original pydantic.ValidationError).

from synapse_sdk.exceptions import ValidationError as SdkValidationError
import pydantic

try:
project = client.projects.retrieve(42)
except SdkValidationError as exc:
# Single SDK-native exception type — never bare pydantic.
print(exc.detail['model']) # 'ProjectV2Detail'
print(exc.detail['detail']) # list of error dicts (no 'input' key)
if isinstance(exc.__cause__, pydantic.ValidationError):
# Full pydantic detail (including raw input) for diagnostics.
for err in exc.__cause__.errors():
print(err['loc'], err['msg'])

The kwarg path is implemented in BackendV2ValidationMixin and is shared between sync and async clients via the MRO:

Error handling

BackendV2Client raises the standard SDK exception hierarchy from synapse_sdk.exceptions — same as the v1 client.

from synapse_sdk.exceptions import (
AuthenticationError,
AuthorizationError,
NotFoundError,
RateLimitError,
ServerError,
ValidationError,
)

try:
project = client.projects.retrieve(42)
except NotFoundError:
...
except AuthenticationError:
# Re-login or refresh access token.
...
except ValidationError as e:
# 400 / 422 with backend `detail` payload preserved on the exception.
# Also raised when `response_model=` decoding fails — see Validation & Models.
...

Resource map

The same surface area is available on BackendV2Client (sync) and AsyncBackendV2Client (async).

Top-level resources

AttributeEndpointsNotable methods
client.authPOST /users/login/login(email, password, set_drf_token=True)
client.tenantsGET /v2/tenants/(...)list, retrieve(code)
client.tokens*/v2/tokens/(...)list, create, retrieve(id), destroy(id)
client.schemasGET /v2/schemas/...annotation_configurations(), file_specifications()
client.data_collections*/v2/data-collections/(...)full CRUD + membership + nested groups(dc_id)
client.data_units*/v2/data-units/(...)full CRUD + permission trio
client.data_filesGET /v2/data-files/(...)list, retrieve(id)
client.ground_truth_datasets*/v2/ground-truth-datasets/(...)full CRUD + nested versions(dataset_id)
client.ground_truthsGET /v2/ground-truths/(...)list, retrieve(id)
client.modelsGET /v2/models/(...)list, retrieve(id)
client.experiments*/v2/experiments/(...)full CRUD + membership family
client.projects*/v2/projects/(...)full CRUD + membership + nested tags(project_id)
client.tasks*/v2/tasks/(...)full CRUD + permission trio
client.assignmentsGET /v2/assignments/(...)list, retrieve(id) + permission trio
client.reviews*/v2/reviews/(...)list, create, retrieve(id) + permission trio
client.members*/v2/members/(...)list, retrieve(id), create
client.groups*/v2/groups/(...)full CRUD (top-level workspace groups)
client.validation_scripts*/v2/validation-scripts/(...)full CRUD
client.workshops*/v2/workshops/(...)list, retrieve(id) + permission trio
client.jobsGET /v2/jobs/(...)list, retrieve(id) (UUID-keyed)
client.job_logsGET /v2/job-logs/(...)list, retrieve(id)
client.pluginsGET /v2/plugins/(...)list, retrieve(id)
client.plugin_releasesGET /v2/plugin-releases/(...)list, retrieve(id)

Membership family

Top-level resources (projects, experiments, data_collections) expose:

.check_permission(id, permission='view')
.permissions(id)
.roles(id)
.grantable_roles(id)
.default_roles()
.invite(id, payload)
.get_join_requests(id)
.request_join(id, payload)
.confirm_join_request(id, payload)
.my_join_requests()
.my_join_request_detail(join_request_id)

Permission trio

Child / leaf resources (tasks, data_units, assignments, reviews, workshops, nested groups (under data-collections), nested versions, nested tags) expose only:

.check_permission(id, permission='view')
.permissions(id)
.roles(id)

Membership helpers — typed payloads

Membership operations on top-level resources (projects, experiments, data_collections) accept typed payload models for the mutating verbs:

from synapse_sdk.clients.backend_v2.models import (
MemberRoleJoinRequestCreateRequest,
PatchedMemberRoleJoinRequestConfirmRequest,
TargetRoleCreateRequest,
)

# 1) Invite a target user/group with a role binding.
client.projects.invite(
project_id,
TargetRoleCreateRequest(target=42, role=7),
)

# 2) Request to join an existing project.
client.projects.request_join(
project_id,
MemberRoleJoinRequestCreateRequest(role=7, message='please add me'),
)

# 3) Confirm / reject a pending join request.
client.projects.confirm_join_request(
project_id,
PatchedMemberRoleJoinRequestConfirmRequest(confirm=True),
)

Plain dicts are still accepted — the wire shape is identical.

The helpers themselves live in resources/_membership.py behind two @runtime_checkable Protocols (SyncMembershipResource / AsyncMembershipResource). Static type checkers (mypy, pyright) and the runtime guards reject sync↔async cross-wiring so a silent coroutine was never awaited symptom can no longer slip through review.

P1 domain quick examples

# Workspace members (top-level /v2/members/)
page = client.members.list(per_page=20)
member = client.members.retrieve(123)

# Workspace groups (top-level — distinct from data-collection groups)
group = client.groups.create({'name': 'reviewers'})
client.groups.partial_update(group.id, {'name': 'qa-reviewers'})

# Validation scripts
script = client.validation_scripts.retrieve(7)

# Workshops (read-only + permission trio)
ws = client.workshops.retrieve(3)
client.workshops.check_permission(3, permission='view')

# Async jobs (UUID-keyed) and their console logs
job = client.jobs.retrieve('00000000-0000-0000-0000-000000000001')
for log in client.job_logs.list(job=job.id, list_all=True):
print(log.message)

# Plugins + releases (read-only)
client.plugins.list(per_page=10)
client.plugin_releases.retrieve(42)

Common patterns

Building request bodies with the auto-generated models

Pydantic models are documented in Validation & Models. Quick example:

from synapse_sdk.clients.backend_v2.models import ProjectV2CreateRequest

payload = ProjectV2CreateRequest(
title='New project',
category='image',
data_collection=10,
)
project = client.projects.create(payload)

You can also pass plain dicts; they are validated by the backend.

Bootstrapping from environment variables

import os

os.environ['SYNAPSE_BACKEND_V2_ACCESS_TOKEN'] = 'syn_...'
os.environ['SYNAPSE_BACKEND_V2_TENANT'] = 'acme'

# All three values can be omitted on the constructor.
client = BackendV2Client('https://api.test.synapse.sh')

Working with nested resources

# Data collection groups
client.data_collections.groups(dc_id=7).list(per_page=20)
client.data_collections.groups(dc_id=7).create({'name': 'g1'})

# Ground truth dataset versions
client.ground_truth_datasets.versions(dataset_id=3).list()

# Project tags
client.projects.tags(project_id=42).list(per_page=10)
client.projects.tags(project_id=42).create({'name': 'urgent', 'category': 'project'})

Observability — last_request_id tracing

Every successful v2 response carries a meta.request_id the backend logs against the request. The transport captures it before unwrap_envelope discards meta, into a contextvars.ContextVar (async-safe and thread-safe by construction). Two surfaces are exposed:

SurfaceReturnsWhen to use
client.last_request_id (property)str | NoneRead the most recent request_id observed in the current context.
client.capture_request_ids() (context manager)NoneReset the slot on enter, restore the outer value on exit — scopes a block of work to its own request_ids.
project = client.projects.retrieve(42)
print(client.last_request_id) # e.g. 'req_01H8...'

with client.capture_request_ids():
page = client.projects.list(per_page=20)
log.info('listed', request_id=client.last_request_id)
# Outer context's request_id is restored here.

The async client mirrors the same surface — both last_request_id and capture_request_ids() are usable from AsyncBackendV2Client. Because ContextVar participates in asyncio.Task context capture, asyncio.gather(call_a(), call_b()) does not race — each task sees its own last_request_id.

The implementation lives in observability.py. record_request_id is the same gate as unwrap_envelope (only triggers when both data and meta are present at the root) — non-enveloped responses leave the slot untouched, so the previous request_id is preserved.

CLI — synapse v2 ...

The SDK ships a read-only CLI sub-command that wraps BackendV2Client for ad-hoc operations and tenant smoke tests. Auth is shared with the existing synapse login flow (~/.synapse/config.json).

synapse v2 projects list --tenant acme --per-page 20
synapse v2 projects retrieve 42 --tenant acme
synapse v2 tokens list --tenant acme
synapse v2 tokens retrieve 7 --tenant acme
synapse v2 tenants list
synapse v2 tenants retrieve 1
Flag / envPurpose
--tenantTenant code. Falls back to SYNAPSE_BACKEND_V2_TENANT env.
--hostAPI host. Defaults to the host from synapse login.
--token / -tAccess token. Defaults to the token from synapse login.
--drf-tokenLegacy DRF token. Usually omitted.
--per-pageList page size (default 20).

The list verbs print a Rich table; projects list also echoes client.last_request_id so backend log correlation is one shell command away. Mutating verbs (create / update / destroy) are deferred to a follow-up PR — they need richer input (JSON body, confirm prompts, dual auth) which inflates the surface beyond a smoke MVP.

The CLI lives at synapse_sdk/cli/v2.py and is registered in synapse_sdk.cli.main via cli.add_typer(v2_app, name='v2').

Performance — lazy imports

BackendV2Client is designed to import cheaply. Two layers participate:

  1. Resource attributes are lazy @property accessors — client.projects only constructs the resource object on first use.
  2. Pydantic models in synapse_sdk.clients.backend_v2.models re-export the auto-generated _generated module via PEP 562 __getattr__. The 1900-line _generated module (137 pydantic classes) is loaded on first attribute access rather than at import time.
# Import-time work: minimal — no _generated load.
from synapse_sdk.clients.backend_v2 import BackendV2Client

# Triggers _generated import (one-time, ~140ms cold).
from synapse_sdk.clients.backend_v2.models import ProjectV2List

Workflows that only touch BackendV2Client (and never reach for a model class directly) skip the ~140ms cold-import cost. From the caller's point of view the lazy export is transparent — from synapse_sdk.clients.backend_v2.models import ProjectV2List works exactly as before.

Optional dependencies

email-validator was removed from the runtime dependency set in Phase 4.

The single EmailStr use site (UserThumbnailInfo.email) is a read-only response field — backend validation is authoritative there, so client-side RFC 5322 enforcement adds no value. A post-codegen patch (_patch_email_str in scripts/sync_v2_schema.py) rewrites every generated EmailStr annotation to str while preserving the max_length=254 constraint, so models continue to validate the documented length without pulling in email-validator at runtime.

If your workflow constructs UserThumbnailInfo directly with hostile input and needs RFC 5322 validation, install email-validator yourself and validate before passing the value. Most callers do not need this.

v1 ↔ v2 endpoint diff

The INVENTORY.md "Diff vs v1" section is auto-generated by make sync-v2-all (or the standalone make gen-v2-diff target). It introspects v1 paths from synapse_sdk/clients/backend/*.py and compares them against the v2 schema (with the /v2/ prefix stripped before matching).

BucketCountMeaning
v1 only31Endpoints exposed by BackendClient that have no v2 equivalent. Continue calling the v1 client for these (agents, storages, the legacy serve_applications/ etc.).
v2 only101New surface area unique to v2 — membership / permission trio routes, workshops, etc.
Shared5Identical method+path on both clients (e.g. GET /jobs/, POST /tasks/). Migrate at your convenience.

The full tables live in INVENTORY.md. Re-run make sync-v2-all whenever the backend ships a new release and the table updates in place.

Migration from v1 (BackendClient)

Concernv1v2
Paginationcount/next/previous/results (offset)next/previous/results (cursor; no count)
Page size parampage_size=per_page=
Tenant headerSynapse-Tenant: Token <code>SYNAPSE-Tenant: <code> (default; opt back via tenant_token_prefix=True)
Access token headerSynapse-Access-Token: Token <token>SYNAPSE-ACCESS-TOKEN: syn_<token>
Method shapeflat (client.list_projects(...))namespaced (client.projects.list(...))
Responseraw dicttyped Pydantic model (*V2List / *V2Detail)

Both clients can coexist; convert call sites incrementally as v2 endpoints become available for your workload.

Schema sync & drift handling

The Pydantic models and INVENTORY.md are derived from the cached OpenAPI schema (schema/openapi.yaml). Use the Make targets to refresh them after the backend ships a new release:

make sync-v2-schema       # download + cache schema only
make gen-v2-models # regen Pydantic models
make gen-v2-inventory # regen INVENTORY.md
make sync-v2-all # all of the above

The script lives at scripts/sync_v2_schema.py. It downloads from api.test.synapse.sh by default — override with --host.

Drift between live backend and OpenAPI (Path γ)

The dev backend occasionally observes shapes that the published OpenAPI schema does not encode (or encodes incorrectly). Rather than hand-edit the generated models, the SDK uses a codegen + override hybrid ("Path γ"):

  1. make gen-v2-models runs datamodel-codegen on the cached schema as the single source of truth.
  2. scripts/sync_v2_schema.py then applies declarative build-time overrides to the generated tree.
  3. Each override is self-diagnosing — once the upstream schema is corrected, the override raises SystemExit so contributors notice the drift has resolved and the override can be removed.

Currently tracked overrides:

DriftLocationOverride
Category10 enum missing data member (live backend returns category=data, schema omits it)_ENUM_DRIFT = {'Category10': ['data']} in _patch_enum_driftAdds the missing enum member after codegen
JobV2Detail.completed / JobV2List.completed declared nullable+required but missing from the schema bodymodel patcher in the same scriptPromotes the attribute to Optional[...] so live null payloads validate
Adding a new override

Document the upstream issue in the script header and include a self-diagnose guard (SystemExit-style assertion) so the override deletes itself from the codebase the moment the schema is fixed. Long-lived undocumented overrides are an anti-pattern.

Open items pending live verification

These items require credentialed dev backend access. The client ships with safe defaults, but bump the linked knob if your tenant disagrees.

  • OQ-1 (resolved — SYN-6854)SYNAPSE-Tenant value format. The dev backend accepts both the raw tenant code and Token <code>; the SDK default is now the raw value (tenant_token_prefix=False). v1-style deployments can opt back via tenant_token_prefix=True.
  • OQ-2 (deferred — needs live data)per_page upper bound. The dev backend returned 200 for every page size from 1 through 10 000 with empty result sets, so the true cap is not yet observable. The SDK does not enforce a cap; track in specs/backend-v2-followup-roadmap/.
  • OQ-3 (resolved — SYN-6854)tokens.create() plaintext disclosure. The dev backend returns the plaintext syn_* token in the create response (AccessTokenCreateResponse.token: SecretStr | None).

See also