Skip to main content

Runtime v2 Client Wiring

The plugin runtime now wires a BackendV2Client alongside the existing v1 BackendClient into every RuntimeContext. Both clients live on the same context object — ctx.client (v1) and ctx.v2_client (v2) — so callers can migrate to v2 endpoints one call-site at a time without an SDK-wide cut-over.

Ticket: SYN-6919 (parent SYN-6893, label 26H1Patch)

This change is the SDK-side counterpart to the v2 export-readiness work in synapse-backend (/v2/tasks/bulk-fetch/, /v2/assignments/bulk-fetch/, /v2/ground-truths/bulk-data/, /v2/ground-truth-events/bulk-data/). The new resource methods on BackendV2Client for those bulk endpoints are delivered by follow-up SDK PRs (SDK-2 / SDK-3 / SDK-4); the caller migration onto ctx.v2_client.* lands in SDK-6 / SDK-7 / SDK-8.

Why a second client field instead of a shim

BackendV2Client is not a drop-in for BackendClient. They differ in pagination shape, header names, method namespaces, and response models (see BackendV2Client — Migration from v1 for the full table). A shim that hides the v2 client behind the v1 surface would either lose v2-only behaviour (cursor pagination, request_id capture, typed Pydantic models) or silently break v1 callers.

The SDK takes the additive route instead — both clients are injected, and each caller chooses which one it wants. There is no SemVer cut-over; the v1 surface is unchanged for external plugin authors.

# RuntimeContext (synapse_sdk/plugins/context/__init__.py)
@dataclass
class RuntimeContext:
logger: BaseLogger
env: PluginEnvironment
job_id: str | None = None
client: BackendClient | None = None # v1 — unchanged
v2_client: BackendV2Client | None = None # v2 — new (SYN-6919, additive)
agent_client: AgentClient | None = None
checkpoint: dict[str, Any] | None = None
language: str | None = None

Factory — create_backend_v2_client()

A new factory in synapse_sdk/utils/auth.py mirrors the long-standing create_backend_client(). It resolves credentials from the same shared env vars as the v1 factory, with v2-specific fallbacks layered on top:

from synapse_sdk.utils.auth import create_backend_v2_client

v2_client = create_backend_v2_client()
# Either a BackendV2Client or None (no token available in env / config).

Environment variable priority

The factory uses a single env-resolution pass — there is one source of truth across both clients so a single synapse login (or the equivalent plugin-runtime env block) bootstraps both at once.

PriorityVariablePurposeShared with v1?
1SYNAPSE_HOSTBackend base URL (no /v2 suffix)Yes
1SYNAPSE_ACCESS_TOKENsyn_* access tokenYes
2SYNAPSE_PLUGIN_RUN_USER_TOKENDRF token injected by the plugin runtimeYes (v1 binds it to authorization_token; v2 binds it to drf_token)
2SYNAPSE_PLUGIN_RUN_TENANTTenant code injected by the plugin runtimeYes
3SYNAPSE_BACKEND_V2_ACCESS_TOKENv2-only access token fallbackNo
3SYNAPSE_BACKEND_V2_DRF_TOKENv2-only DRF token fallbackNo
3SYNAPSE_BACKEND_V2_TENANTv2-only tenant fallbackNo

If none of SYNAPSE_ACCESS_TOKEN, SYNAPSE_PLUGIN_RUN_USER_TOKEN, SYNAPSE_BACKEND_V2_ACCESS_TOKEN, or SYNAPSE_BACKEND_V2_DRF_TOKEN is set, the factory returns None — identical to the v1 factory's behaviour, so dev environments without credentials remain runnable.

Why tenant_token_prefix=True is forced

Every existing plugin-runtime deployment already expects the v1-style SYNAPSE-Tenant: Token <code> header. Switching to the v2-native raw value (SYNAPSE-Tenant: <code>) inside the runtime would silently break tenant routing for in-flight plugin runs. The factory therefore passes tenant_token_prefix=True unconditionally; standalone callers that need the v2-native value should construct BackendV2Client directly. See BackendV2Client — Authentication for the per-header breakdown.

Where the wiring lives — 5 instantiation sites

RuntimeContext is built in exactly five places. All five now call create_backend_v2_client() next to create_backend_client() and pass the result as v2_client=:

SiteFileTrigger
Entrypointsynapse_sdk/plugins/entrypoint.pySubprocess main() — the entrypoint a plugin runtime container exec's
LocalExecutorsynapse_sdk/plugins/executors/local.pyIn-process synapse plugin run --mode local
Ray Tasksynapse_sdk/plugins/executors/ray/task.pyRay Actor-backed --mode task
Ray Jobsynapse_sdk/plugins/executors/ray/job.pyRay Jobs API-backed --mode job
Ray Pipelinesynapse_sdk/plugins/executors/ray/pipeline.pyMulti-action pipelines on Ray

The factory is invoked once per RuntimeContext build; for the Ray-based executors the call happens inside the actor / job process so the client uses the env block the runtime injected for that worker, not the driver's env.

Caller pattern — class-based action

External plugin authors access the v2 client through self.v2_client (class-based actions) or ctx.v2_client (function-based / step-based actions). The surface mirrors BackendV2Client exactly — there is no SDK-side wrapper.

from synapse_sdk.plugins.action import BaseAction
from synapse_sdk.plugins.enums import PluginCategory


class MyExportAction(BaseAction):
category = PluginCategory.EXPORT

def run(self):
# v1 path — still works exactly as before
if self.client:
projects = self.client.list_projects()

# v2 path — guard against None when running without credentials
if self.v2_client is not None:
page = self.v2_client.tasks.list(project=42, per_page=50)
for task in page.results:
...

# request_id correlation for backend log lookup
with self.v2_client.capture_request_ids():
self.v2_client.assignments.list(project=42, list_all=True)
self.logger.info(
'export-listing',
request_id=self.v2_client.last_request_id,
)

None-guard rule

Both ctx.client and ctx.v2_client are typed Optional and may be None in dev environments. The factory only returns a client when credentials are resolvable from env / ~/.synapse/config.json. The runtime injects credentials in production, but a synapse plugin run --mode local invocation against an unconfigured shell will produce a context with both fields set to None. Plugin code must gate v2 calls with if ctx.v2_client is not None: (or if self.v2_client is not None:).

Coexistence policy — additive, no cut-over

Concernv1 (self.client)v2 (self.v2_client)
Field availabilityUnchanged — same as before SYN-6919New, defaults to None
When it's wiredWhenever any of SYNAPSE_ACCESS_TOKEN, SYNAPSE_PLUGIN_RUN_USER_TOKEN are setWhenever any of SYNAPSE_ACCESS_TOKEN, SYNAPSE_PLUGIN_RUN_USER_TOKEN, SYNAPSE_BACKEND_V2_ACCESS_TOKEN, SYNAPSE_BACKEND_V2_DRF_TOKEN are set
Tenant headerSYNAPSE-Tenant: Token <code>SYNAPSE-Tenant: Token <code> (runtime forces v1-compat prefix; see factory note)
PaginationOffset (page_size=, count in response)Cursor (per_page=, no count)
Response shapeRaw dictTyped Pydantic models
Migration cadencen/aPer call-site, gated by per-category policy (SYN-7082)

SemVer impact is minor (additive). External plugins that never reference v2_client see zero behavioural change.

Per-category v2 selection

Wiring a v2_client does not by itself route a caller onto v2. The in-tree callers select v1/v2 through use_v2(ctx, *, category=None) (synapse_sdk/plugins/actions/_v2_switch.py — the SSOT), which consults a per-action-category policy table:

_V2_CATEGORY_POLICY = {PluginCategory.EXPORT: True}  # default v1; EXPORT → v2

Override priority (highest → lowest):

  1. SYNAPSE_FORCE_V1_EXPORT truthy → always v1 (runtime rollback).
  2. ctx.v2_client missing / None → v1.
  3. _V2_CATEGORY_POLICY lookup → default v1, EXPORT → v2.

So today only export handlers take v2; to_task fetch and dataset download stay on v1 even with a v2_client wired. See Per-category v2 opt-in for the full routing table.

Export delegation also rides this client

For over-threshold exports the SDK delegates processing to a backend async-job via v2_client.plugin_exports.create(...), v2_client.async_jobs.stream_progress(job_id) (SSE), and v2_client.async_jobs.retrieve(job_id) — so a wired v2_client is a precondition for the delegation gate. See Export Actions — 서버사이드 위임.

Migration roadmap

The wiring landed in SDK-1 is intentionally inert until the caller-side migrations land — at the moment no in-tree action references ctx.v2_client. Three follow-up SDK PRs convert the existing v1 call sites onto the v2 surface:

Follow-upScope
SDK-2 / SDK-3 / SDK-4Add v2 resource methods for the bulk endpoints (tasks.bulk_fetch, assignments.bulk_fetch, ground_truths.bulk_data, ground_truth_events.bulk_data).
SDK-6Migrate export action target handlers from self.client.list_tasksself.v2_client.tasks.bulk_fetch.
SDK-7Migrate assignment / review export paths.
SDK-8Migrate ground-truth dataset export paths.

Each follow-up is independent and keeps the v1 path intact so external plugins continue to work unchanged. Once every in-tree caller is on v2 the SDK will revisit whether ctx.client can be deprecated — that decision is out of scope for SYN-6919.

Per-category routing update

The SDK-6/7/8 callers all landed, but SYN-7082 subsequently gated v2 selection behind a per-category policy (see Per-category v2 selection above). Net result today: export handlers run on v2; the to_task fetch and dataset download callers were narrowed back to v1 (their v2 branches remain in source as dead code, re-activated by adding the category to _V2_CATEGORY_POLICY). Moving a category onto v2 is now a policy-table edit, not a new SDK-N cut-over PR.

Testing your own plugin

tests/plugins/context/test_v2_client_wiring.py parametrises the wiring check across all five instantiation sites — any new executor that builds a RuntimeContext should be added to that list so a missing v2_client= parameter is caught at test time, not at runtime.

For external plugin tests, monkey-patch the factory:

import synapse_sdk.utils.auth as auth_module
from synapse_sdk.clients.backend_v2 import BackendV2Client


def test_action_uses_v2_client(monkeypatch):
fake = BackendV2Client('https://api.test.synapse.sh', access_token='syn_x')
monkeypatch.setattr(auth_module, 'create_backend_v2_client', lambda: fake)
# ... build LocalExecutor / call entrypoint / etc.

See also