Python Server SDK
Introduction
The Python Server SDK is a server-side SDK that evaluates configs and their targeting rules. Upon initialization, it retrieves configs and targeting rules from ConfigDirector services. From that point on, it evaluates configs locally for any user context, and receives updates via server sent events (SSE) when configs are updated on the dashboard or via the admin API.
The minimum Python version supported is 3.10.
Installation
The SDK can be installed from PyPI: https://pypi.org/project/configdirector-server-sdk/
pip install configdirector-server-sdk
Configure and initialize the client
- Create an instance of the client providing your server SDK key. You can retrieve a server SDK key for each environment under
SDK keysin the dashboard's navigation panel. - Initialize the client to initiate its connection lifecycle.
from configdirector import create_client
client = create_client("YOUR-SERVER-SDK-KEY")
# Blocks until the client is initialized or it times out.
# If initialization times out, the client will continue attempting to
# initialize in the background.
client.initialize()
Additional configuration options
metadata
The metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.
from configdirector import Metadata, create_client
client = create_client(
"YOUR-SERVER-SDK-KEY",
metadata=Metadata(app_name="YOUR-APP-NAME", app_version="1.0.2"),
)
client.initialize()
log_level and logger
The SDK logs through the standard library to a logger named configdirector. Logging levels, formatting, and destinations can be controlled by accessing the logger:
import logging
logging.getLogger("configdirector").setLevel(logging.DEBUG)
The SDK sets no level of its own, so this logger follows the usual logging rules. With nothing
configured at all, Python still surfaces warnings and errors on stderr. If you would rather not configure logging, the log_level parameter can be used to set a level on the logger:
client = create_client("YOUR-SERVER-SDK-KEY", log_level="DEBUG")
The logger can be overridden by passing any object implementing ConfigDirectorLogger (a
different stdlib logger, or your own adapter) in the logger parameter:
client = create_client("YOUR-SERVER-SDK-KEY", logger=logging.getLogger("my_app.configdirector"))
connection
The connection parameter accepts a ConnectionOptions object with the following optional values:
mode- The connection mode, which can be either
streamingorpolling. It is recommended to use the default ofstreamingunless you have a specific need to usepollinginstead. - Defaults to
streaming
- The connection mode, which can be either
polling_interval- Only used in
pollingmode. The interval, in seconds, to poll ConfigDirector services for updates. A value below the minimum of60seconds is raised to60seconds and a warning is logged. See Polling intervals. - Defaults to
5minutes (300seconds)
- Only used in
timeout- The timeout, in seconds, to be used in initialization. This is how long the
initializemethod will wait for data from ConfigDirector services. If the timeout is reached,initializewill return but the client will still be in an unready status and returning default values. The client will continue to attempt to connect and retrieve config values in the background. - Defaults to
3seconds
- The timeout, in seconds, to be used in initialization. This is how long the
url- The base URL used to connect to ConfigDirector services
- This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services
from configdirector import ConnectionOptions, create_client
client = create_client(
"YOUR-SERVER-SDK-KEY",
connection=ConnectionOptions(mode="polling", polling_interval=600, timeout=5),
)
telemetry
Telemetry tuning. It is unlikely these settings need to be adjusted. However, in cases where your application has a large number of evaluations per second, you can adjust these settings to tune the memory footprint and frequency of telemetry requests.
Keep in mind that ConfigDirector relies on these telemetry events to provide insights and features related to the configs being used.
The telemetry object accepts the following optional values:
event_queue_limit- The size limit of telemetry event queues. If the size limit is reached before the events are flushed to the network, older events will be dropped.
- ConfigDirector keeps a count of dropped events. If the number of dropped events is higher than 50% of the total events, ConfigDirector will issue an alert in the dashboard.
- A number between 100 and 100,000. Defaults to 5,000.
flush_interval- How often events are flushed and sent over the network in seconds.
- Decrease this number if your application consistently captures a large number of events in short periods of time in order to reduce memory footprint from a large event queue.
- Defaults to 30 seconds.
from configdirector import TelemetryOptions, create_client
client = create_client(
"YOUR-SERVER-SDK-KEY",
telemetry=TelemetryOptions(event_queue_limit=10_000, flush_interval=15),
)
Hooks
Hooks are events you can subscribe to in order to be notified of some key actions from the client. In order to subscribe to hooks you can provide a handler in the options, or use the on method which returns a Subscription:
subscription = client.on("configs_updated", lambda event: print(event.keys))
from configdirector import ClientHooks, create_client
client = create_client(
"YOUR-SERVER-SDK-KEY",
hooks=ClientHooks(config_evaluated=lambda event: print(event.evaluation)),
)
The following hooks are available:
client_ready: ClientReadyEvent- Emitted when the client is initialized. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs.
configs_updated: ConfigsUpdatedEvent- Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization when an entire config payload is received. After initialization, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
keys: list[str]- The config keys that were included in the payload from the server, and the keys of configs whose targeting rules use a segment the payload included, sorted.removed_keys: list[str]- The keys of configs the payload removed, sorted. A config is removed when a full payload no longer includes it. Empty when nothing was removed.
config_evaluated: ConfigEvaluatedEvent- Emitted whenever a config is evaluated. This includes calls to
get_valueand evaluations delivered to watchers viawatch. evaluation: ConfigEvaluation- AConfigEvaluationobject containing the details of the evaluation:key: str- The config key that was evaluatedvalue: str | int | float | bool | dict | list- The value the config evaluated to. It can be the default value provided toget_valueorwatch. For example, if the config was evaluated before the client was initialized.value_id: str | None- The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending thevalue. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It may beNoneif the evaluation had to fall back to the default value.is_default: bool- Whether or not the evaluation fell back to the default value provided in code.reason: EvaluationReason- The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will befound-match. If the evaluation had to fall back to the default value, thereasonwill encode why the fallback was required:client-not-ready- The evaluation happened before the client finished initializationconfig-state-missing- The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.invalid-number- The config was requested as a number but the value received from the server could not be converted to a number.invalid-boolean- The config was requested as a boolean but the value received from the server could not be converted to a boolean.invalid-json- The config was requested as a JSON object but the value received from the server could not be converted to a JSON object.type-mismatch- The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.value-missing- The config key was present in the payload received from the server, but it carried no value.
context: Context | None- The user context that was provided to the evaluation function, orNoneif no context was provided.
- Emitted whenever a config is evaluated. This includes calls to
Retrieve config values
Retrieving config values via get_value, and subscribe to updates via watch. The first argument is the config key, the second is the default value. The type of the default value provided determines the type the config is parsed as. The default value is returned if the config is not available or cannot be parsed as the given type:
# Retrieve config values
retries = client.get_value("max-retries", 3)
theme = client.get_value("theme", "light")
limits = client.get_value("rate-limits", {"per_minute": 60}) # JSON config
# Watch for config updates
def on_change(value: bool) -> None:
print(f"new-checkout is now {value}")
subscription = client.watch("new-checkout", False, on_change)
# Call close when the subscription is not longer needed
subscription.close()
Evaluate config values with a user context
get_value accepts three arguments, the first argument is the config key, the second one is the default value to be returned if the client is not yet initialized, and the third and optional argument is a user context to be used for targeting rules evaluation:
from configdirector import Context
client.get_value("my-boolean-config-key", False, Context(id="user-id", name="Example User"))
watch accepts four arguments, the first is the config key, the second argument is the default value, the third argument is a function that will be executed when the config value is updated, and the fourth and optional argument is a user context:
When a full payload no longer includes the watched config, the function is called with the default value, so it always agrees with what get_value would return.
def on_change(value: bool) -> None:
print(f"new-checkout is now {value}")
subscription = client.watch(
"new-checkout",
False,
on_change,
Context(
id="user-id",
name="Example User",
traits={"region": "North America"}
)
)
Test your code
Use the SDK's configdirector.testing module to test the code that reads your configs and flags. It creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector and the telemetry are replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened, no telemetry is sent, and no thread is started. The module ships in the package, so there is nothing else to install.
from configdirector.testing import create_test_client
with create_test_client({"new-checkout": True, "max-items": 20}) as test_client:
service = CheckoutService(test_client.client)
test_client.client.initialize()
assert service.is_new_checkout_enabled("user-123")
test_client.set_value("new-checkout", False)
assert not service.is_new_checkout_enabled("user-123")
test_client.client is a ConfigDirectorClient, so it goes anywhere your code accepts one. It starts uninitialized, like a production client, because the code under test usually owns the call to initialize; with no hold or failure armed, initialize completes at once with the stored values. The test client is a context manager that closes its client on exit; unlike the client's own context manager, entering it does not initialize the client.
Module-level clients
An application that creates and initializes its client when a module loads, as the Flask sample did, runs the real client before any test can intervene, which opens a connection and starts threads. A monkeypatch also has to reach every module that bound the name: from configdirector_client import client binds its own client, so patching configdirector_client.client does not change what the importing module sees. Structure the application around a lazy getter instead, and replace that in tests:
import os
import threading
from configdirector import ConfigDirectorClient, create_client
_client: ConfigDirectorClient | None = None
_client_lock = threading.Lock()
def get_client() -> ConfigDirectorClient:
global _client
with _client_lock:
if _client is None:
_client = create_client(os.environ["CONFIGDIRECTOR_SERVER_SDK_KEY"])
_client.initialize()
return _client
The lock matters on a threaded server: without it, two first requests arriving together each build and initialize a client.
import pytest
import app
from configdirector.testing import create_test_client
def test_new_checkout(monkeypatch: pytest.MonkeyPatch) -> None:
with create_test_client({"new-checkout": True}) as test_client:
test_client.client.initialize()
monkeypatch.setattr(app, "get_client", lambda: test_client.client)
assert app.test_client().get("/checkout").json["new-checkout"] is True
Patch the name in the module that calls it (app.get_client when app.py does from configdirector_client import get_client), and initialize the test client where the application's getter would have.
Values
create_test_client, set_value, and replace_values take native values, and the config type follows from each value's type: a bool; an int (an integer config); a float (a float config); a str; or a dict with str keys or a list (a JSON config). A str is always a string config, so JSON text meant as a JSON config is given as json.loads(text). Every value is served as an unconditional config, so every context receives the same value; a test that needs different values per context is written as one test per value. None, an unsupported type such as a tuple or a set, or a dict with a non-str key raise ConfigDirectorTypeError; a blank key, a non-finite number, or an integer with too many digits to write out raise ConfigDirectorValidationError. In every case nothing changes.
Reads behave as they do in production: set_value("k", True) read as a string returns the in-code default value with the type-mismatch reason, set_value("k", 2.5) read with an int default returns the default with the invalid-number reason, and set_value("k", "") serves the in-code default value with the value-missing reason. Reasons arrive on the config_evaluated event. JSON objects keep their insertion order.
Controls
| Control | Effect |
|---|---|
set_value(key, value) | Stores the value and, once the client is connected, delivers an update carrying only key. Watches of key, configs_updated handlers, and reads see it before the call returns. |
remove_value(key) | Removes the value and delivers a full update without it. Reads return the in-code default value with the config-state-missing reason, watches of key receive the default, and configs_updated lists key in removed_keys. |
replace_values(values) | Replaces every stored value, disarms any armed hold or failure, and delivers a full update. Use it to reset a test client shared across tests. |
hold_initialization() | The next initialize waits until complete_initialization() or fail_initialization(), or until the client's timeout elapses. initialize blocks, so a test calls it on another thread. |
complete_initialization() | Delivers the stored values to the held initialize, on the calling thread, so the client is ready when the call returns. Called while a hold is armed but not picked up, it disarms the hold. |
fail_initialization() | Fails the held initialize the way an invalid SDK key does: it completes promptly, the client is not ready, and the error is logged. Called with no initialize held, it arms the next one to fail. |
After a failed attempt, the next initialize succeeds and delivers the values stored in the meantime.
with create_test_client({"new-checkout": True}, timeout=0.5) as test_client:
test_client.fail_initialization()
test_client.client.initialize()
assert not test_client.client.is_ready
assert not service.is_new_checkout_enabled("user-123")
Options
create_test_client(values, timeout=..., logger=...) takes timeout (the client's connection timeout in seconds, which bounds how long a held initialize waits; defaults to the SDK's production timeout, and a timeout given to initialize itself takes precedence) and logger (defaults to the standard library logger named configdirector, so pytest's caplog captures it).
What to expect
- The first update is delivered while
initializeconnects, so watches registered beforeinitializerun before it returns, andconfigs_updatedfires beforeclient_ready. - Watches and handlers run on the thread that calls
set_value,remove_value,replace_values,complete_initialization, orinitialize, not on a transport thread. - A held
initializethat times out, or that is interrupted byclose, leaves the client not ready until the nextinitialize, and logs the SDK's timeout warning; production streaming would keep retrying in the background. Readiness is sticky: once the client is ready, a later held, failed, or timed-out attempt leaves it ready with its previous values, and completing such an attempt fires noclient_ready. - Watches fire on every update carrying their key, whether or not the value changed, as in production.
remove_valueandreplace_valuesdeliver a full update, so they also fire the watches of every remaining key. - An exception raised by a watch or handler is logged and does not propagate, as with the production transports.
- After
close,is_readyis false,get_valueserves the last values,get_all_configsreturns an empty dict,initializeraisesConfigDirectorValidationError, every watch and event handler has been removed, the test client's controls are silent no-ops, and no SDK thread is left running.