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.
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.
| Priority | Variable | Purpose | Shared with v1? |
|---|---|---|---|
| 1 | SYNAPSE_HOST | Backend base URL (no /v2 suffix) | Yes |
| 1 | SYNAPSE_ACCESS_TOKEN | syn_* access token | Yes |
| 2 | SYNAPSE_PLUGIN_RUN_USER_TOKEN | DRF token injected by the plugin runtime | Yes (v1 binds it to authorization_token; v2 binds it to drf_token) |
| 2 | SYNAPSE_PLUGIN_RUN_TENANT | Tenant code injected by the plugin runtime | Yes |
| 3 | SYNAPSE_BACKEND_V2_ACCESS_TOKEN | v2-only access token fallback | No |
| 3 | SYNAPSE_BACKEND_V2_DRF_TOKEN | v2-only DRF token fallback | No |
| 3 | SYNAPSE_BACKEND_V2_TENANT | v2-only tenant fallback | No |
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=:
| Site | File | Trigger |
|---|---|---|
| Entrypoint | synapse_sdk/plugins/entrypoint.py | Subprocess main() — the entrypoint a plugin runtime container exec's |
| LocalExecutor | synapse_sdk/plugins/executors/local.py | In-process synapse plugin run --mode local |
| Ray Task | synapse_sdk/plugins/executors/ray/task.py | Ray Actor-backed --mode task |
| Ray Job | synapse_sdk/plugins/executors/ray/job.py | Ray Jobs API-backed --mode job |
| Ray Pipeline | synapse_sdk/plugins/executors/ray/pipeline.py | Multi-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
| Concern | v1 (self.client) | v2 (self.v2_client) |
|---|---|---|
| Field availability | Unchanged — same as before SYN-6919 | New, defaults to None |
| When it's wired | Whenever any of SYNAPSE_ACCESS_TOKEN, SYNAPSE_PLUGIN_RUN_USER_TOKEN are set | Whenever any of SYNAPSE_ACCESS_TOKEN, SYNAPSE_PLUGIN_RUN_USER_TOKEN, SYNAPSE_BACKEND_V2_ACCESS_TOKEN, SYNAPSE_BACKEND_V2_DRF_TOKEN are set |
| Tenant header | SYNAPSE-Tenant: Token <code> | SYNAPSE-Tenant: Token <code> (runtime forces v1-compat prefix; see factory note) |
| Pagination | Offset (page_size=, count in response) | Cursor (per_page=, no count) |
| Response shape | Raw dict | Typed Pydantic models |
| Migration cadence | n/a | Per 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):
SYNAPSE_FORCE_V1_EXPORTtruthy → always v1 (runtime rollback).ctx.v2_clientmissing /None→ v1._V2_CATEGORY_POLICYlookup → 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.
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-up | Scope |
|---|---|
| SDK-2 / SDK-3 / SDK-4 | Add v2 resource methods for the bulk endpoints (tasks.bulk_fetch, assignments.bulk_fetch, ground_truths.bulk_data, ground_truth_events.bulk_data). |
| SDK-6 | Migrate export action target handlers from self.client.list_tasks → self.v2_client.tasks.bulk_fetch. |
| SDK-7 | Migrate assignment / review export paths. |
| SDK-8 | Migrate 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.
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
- BackendV2Client — the underlying v2 client this page wires
- RuntimeContext — the dataclass that now carries
v2_client - Local Execution —
LocalExecutorreference - Ray Execution — Ray task / job / pipeline executors
- BackendClient (v1) — the existing v1 client that continues to coexist