Initialization
Core(SDKConfig(...)).initialize(), direct-data vs sdk_key modes, and opt-in background refresh
Core is the SDK entry point. You construct it with an SDKConfig, then call
initialize(). An SDKConfig must carry exactly one config source:
data— a preloaded config dict. Initialization makes no network call. Ideal for unit tests, CI, and any environment that loads config out of band.sdk_key— the SDK fetches config over HTTPS through the built-inhttpx-backed transport.initialize()is synchronous and blocks until the fetch completes or raises.
Both forms return the same Core instance from initialize(), at which point
core.is_ready is True.
Public symbols used on this page are importable from convert_sdk:
Core, SDKConfig, TransportConfig, RefreshConfig, LifecycleEvent,
InvalidConfigError, ConfigLoadError, TransportError.
Direct config (offline, no network)
Pass a config dict as data=. No transport is constructed or used:
from convert_sdk import Core, SDKConfig
project_config = {
"account_id": "1001",
"project": {"id": "2002", "name": "Demo"},
"experiences": [],
"features": [],
"goals": [],
}
core = Core(SDKConfig(data=project_config)).initialize()
assert core.is_readyThis form is safe in unit tests, offline tooling, and environments where config
is managed outside the SDK. No httpx client is created.
SDK key mode (remote config fetch)
Read the key from the environment — never hard-code credentials:
import os
from convert_sdk import Core, SDKConfig
core = Core(SDKConfig(sdk_key=os.environ["CONVERT_SDK_KEY"])).initialize()
assert core.is_readyinitialize() fetches config over HTTPS from https://cdn-4.convertexperiments.com
and blocks until the response arrives. Network failures raise ConfigLoadError;
malformed config raises InvalidConfigError. A non-HTTPS base_url raises
TransportError at TransportConfig construction, before any network I/O
(NFR8: TLS-only transport).
Customise the transport endpoint, timeout, or TLS behavior via
TransportConfig on SDKConfig.transport:
import os
from convert_sdk import Core, SDKConfig, TransportConfig
core = Core(
SDKConfig(
sdk_key=os.environ["CONVERT_SDK_KEY"],
environment="staging",
transport=TransportConfig(
base_url="https://cdn-4.convertexperiments.com",
timeout=5.0,
auth_secret=os.environ.get("CONVERT_AUTH_SECRET"),
),
)
).initialize()You can also request a low-cache config route, which matches the JS SDK
_conv_low_cache=1 query parameter:
import os
from convert_sdk import Core, SDKConfig
core = Core(
SDKConfig(
sdk_key=os.environ["CONVERT_SDK_KEY"],
cache_level="low",
)
).initialize()Context-manager lifecycle
Core implements the context-manager protocol. Prefer it in long-lived services
and scripts so transport resources are always released — even if an exception is
raised inside the block:
from convert_sdk import Core, SDKConfig
with Core(SDKConfig(data=project_config)).initialize() as core:
context = core.create_context("visitor-001")
result = context.run_experience("checkout-experiment")
# ... evaluate / track ...
# core.close() is called automatically on exit, stopping background threads
# and closing the httpx transport if Core created it.Using the context-manager form is equivalent to calling core.close() in a
finally block.
Creating visitor contexts
Once initialized, call core.create_context(visitor_id) to get a
per-visitor Context. Pass visitor_attributes for audience qualification:
context = core.create_context(
"visitor-abc",
visitor_attributes={"country": "DE", "plan": "pro"},
)Attributes are copied defensively — later mutations to the dict you pass never
affect the context. The context is caller-scoped; Core does not cache it.
Create one per request (or per visitor lifetime) and reuse it within that scope.
See Visitor Context for the full per-visitor API.
Reusing Core across requests
Core is designed as a long-lived singleton. Create one instance at application
startup and share it across requests. The tracking queue and snapshot are
thread-safe:
# Application startup (module level or app factory):
core = Core(SDKConfig(sdk_key=os.environ["CONVERT_SDK_KEY"])).initialize()
# Per-request handler:
def handle_request(visitor_id: str) -> None:
context = core.create_context(visitor_id)
result = context.run_experience("checkout-flow")
...Error handling
Errors raised during initialization are all subclasses of ConvertSDKError:
| Error class | When raised |
|---|---|
InvalidConfigError | Neither sdk_key nor data provided; both provided; sdk_key is empty; data is not a dict; cache_level invalid; batch_size / auto_flush_interval_ms invalid |
ConfigLoadError | Network or HTTP error while fetching config with sdk_key |
TransportError | Non-HTTPS base_url, or transport construction failure |
import os
from convert_sdk import Core, SDKConfig, ConfigLoadError, InvalidConfigError
try:
core = Core(SDKConfig(sdk_key=os.environ["CONVERT_SDK_KEY"])).initialize()
except ConfigLoadError as exc:
# Network error or non-2xx response fetching the config.
print("config fetch failed:", exc)
except InvalidConfigError as exc:
# Config payload is malformed (missing required fields, wrong types, etc.)
print("invalid config:", exc)After initialize() succeeds, normal evaluation and tracking outcomes are
never raised — they are returned as typed result objects (e.g.
ExperienceResult | None, ConversionResult). Exceptions signal programmer
errors or delivery failures only.
Automatic config refresh (opt-in, remote mode)
Post-MVP (Phase 2, FR31). Off by default. Omitting
refresh=(or passingrefresh=None) preserves MVP behavior: no daemon thread, no refresh events, no added cost.
Long-running services can opt into periodic background config refresh by
supplying a RefreshConfig on SDKConfig.refresh. The SDK starts one daemon
worker thread that re-fetches config on a configurable interval and
atomically swaps the immutable snapshot. In-flight evaluations always see
a single coherent snapshot — the swap is mutex-guarded (see
ADR 0001).
import os
from convert_sdk import Core, SDKConfig, RefreshConfig
core = Core(
SDKConfig(
sdk_key=os.environ["CONVERT_SDK_KEY"],
refresh=RefreshConfig(
interval_seconds=300.0, # base poll period (default: 5 minutes)
jitter_seconds=30.0, # +U(0, jitter) per cycle — avoids thundering herd
backoff_factor=2.0, # exponential backoff multiplier on failures
backoff_max_seconds=600.0, # backoff ceiling (never tight-loops on failure)
),
)
).initialize()
# Optionally trigger an immediate out-of-band refresh:
core.refresh_now() # fire-and-forget; returns before the fetch completes
# Graceful shutdown stops the worker thread:
core.close()refresh_now() is a safe no-op when refresh is disabled (refresh=None),
when the SDK is in direct-config (data) mode, or before initialize().
CONFIG_UPDATED lifecycle event
On every successful snapshot swap the SDK emits LifecycleEvent.CONFIG_UPDATED
on the event bus, carrying the new account_id, project_id, and per-type
entity_counts. Subscribe to bust any caches derived from config:
from convert_sdk import LifecycleEvent
core.on(
LifecycleEvent.CONFIG_UPDATED,
lambda payload, error=None: my_cache.clear(),
)Handlers registered with core.on(event, handler) are safe to call before
initialize(). A handler that raises is isolated and logged; it never disrupts
evaluation or delivery.
Context objects and snapshot coherence
A Context retains the snapshot that was current when it was created. This
gives each request a coherent view for its full duration even if a refresh fires
mid-request. Create a fresh context (per request or per visitor interaction) to
pick up the latest config.
Threading and process model
- One refresh worker per
Coreinstance, running as a daemon thread. - Refresh is process-local — no cross-process coordination.
- Background failures never crash the host process. The prior good snapshot keeps serving and the failure is logged through the diagnostic logger.
- Do not rely on refresh surviving a
fork()withoutexec(). The daemon thread is not duplicated into the child. In pre-fork servers (Gunicorn, Celery prefork) re-initialize the SDK in each worker process after fork. - Supplying a
RefreshConfigin direct-config (data) mode starts no worker — there is no remote endpoint to poll — and the SDK logs arefresh.skippeddiagnostic.
RefreshConfig validation
A misconfigured RefreshConfig raises InvalidConfigError at construction:
| Rule | Error condition |
|---|---|
interval_seconds > 0 | Zero or negative value |
0 <= jitter_seconds <= interval_seconds | Negative, or greater than interval |
backoff_factor >= 1.0 | Less than 1.0 |
backoff_max_seconds >= interval_seconds | Less than interval |
See the full field reference in Configuration.
What to read next
- Configuration — field-by-field reference for
SDKConfig,TransportConfig,RefreshConfig - Code Examples —
run_experience,run_feature,track_conversion,core.flush, lifecycle events - Visitor Context —
create_context,set_attributes,set_segments - Extending — custom transport, custom data store, event-bus observers