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-in httpx-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_ready

This 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_ready

initialize() 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 classWhen raised
InvalidConfigErrorNeither 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
ConfigLoadErrorNetwork or HTTP error while fetching config with sdk_key
TransportErrorNon-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 passing refresh=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 Core instance, 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() without exec(). 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 RefreshConfig in direct-config (data) mode starts no worker — there is no remote endpoint to poll — and the SDK logs a refresh.skipped diagnostic.

RefreshConfig validation

A misconfigured RefreshConfig raises InvalidConfigError at construction:

RuleError condition
interval_seconds > 0Zero or negative value
0 <= jitter_seconds <= interval_secondsNegative, or greater than interval
backoff_factor >= 1.0Less than 1.0
backoff_max_seconds >= interval_secondsLess than interval

See the full field reference in Configuration.

What to read next

  • Configuration — field-by-field reference for SDKConfig, TransportConfig, RefreshConfig
  • Code Examplesrun_experience, run_feature, track_conversion, core.flush, lifecycle events
  • Visitor Contextcreate_context, set_attributes, set_segments
  • Extending — custom transport, custom data store, event-bus observers