neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
AI가 작성한 코드를 위한 기능 플래그 플랫폼
The feature flag platform built for the age of AI-written code. Config is git-native, so every flag change is a reviewable commit — diff, author, and timestamp. Nine native SDKs, sub-millisecond local
개발 도구Python · ShellGitHub @quonfig온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Quonfig SDK for Python — feature flags, live config, and dynamic log levels
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Python SDK for Quonfig.
logger_path is passed through verbatim — the SDK does not normalize it, so
callers can author config rules against whatever shape their host language
prefers (dotted, double-colon, slash, etc.).
Attach QuonfigLoggerFilter to any logger or handler and the SDK will gate
records against logger_key. The record's name flows into context verbatim
as quonfig-sdk-logging.key, so a single config can drive per-logger rules.README 전문 보기 ↗Python SDK for Quonfig.
pip install quonfig
from quonfig import Quonfig
client = Quonfig(sdk_key="sdk-...")
client.init()
value = client.get_string("my.key", default="fallback")
enabled = client.is_feature_enabled("my.flag")
get_string, get_int, get_float, get_bool, get_string_list,
get_json and get_duration (seconds) return the config's value when it has
the getter's type. When there is no such value, what you get depends on
default= and on_no_default:
| Situation | With default= | No default, on_no_default="error" (the default) | No default, "warn" / "ignore" | Logged |
|---|---|---|---|---|
| Value of the getter's type | the value | the value | the value | no |
| Missing key, or no rule matched | default | raises QuonfigKeyNotFoundError | None | only under "warn" |
Value of another type (e.g. get_bool on a string config) | default | raises QuonfigTypeMismatchError | None | once per key |
Malformed stored or ENV_VAR value (an int "twelve", a duration "30s") | default | raises QuonfigEnvVarCoerceError | None | once per key |
| ENV_VAR not set | default | raises QuonfigEnvVarNotSetError | None | once per key |
| Secret cannot be decrypted | default | raises QuonfigDecryptionError | None | once per key |
Strings are never coerced: a stored "false" is a string, so get_bool
reports a type mismatch instead of returning True or False. The only
conversions are between numbers, and only when exact: get_float reads an int
config (30 -> 30.0), and get_int reads a double config holding a whole
number within +/-(2^53-1) (3.0 -> 3; 3.9 is a type mismatch, never
truncated). default is returned exactly as you passed it. get is untyped:
it returns any stored value, and resolution errors follow the same rule.
The *_details getters (get_bool_details, get_string_details, ...) never
raise and agree with the plain getters: a value of another type is
reason="ERROR", error_code="TYPE_MISMATCH", and a value that cannot be
parsed as its config type (a malformed ENV_VAR value, a malformed stored
number or duration) is error_code="PARSE_ERROR". An unset ENV_VAR or a
decryption failure is error_code="GENERAL".
evaluate_details(key, contexts=None) reads a config of any type in its
stored form, with the same details: a duration comes back as its ISO 8601
text exactly as stored ("PT90S", where get_duration returns 90.0), and
value_type names the stored type. ENV_VAR provision and decryption still
apply. It is for OpenFeature providers and other callers that do their own
typing.
is_feature_enabled never raises: it returns True only for a bool True,
and otherwise (a missing key, a flag with no matching rule, a non-bool value
such as the string "true", or any resolution error) returns its default
(False unless you pass one), logging once per key.
A wrong decryption key or an unset ENV_VAR therefore degrades to your defaults
with one WARN per key. If a broken config must stop your service, read it
without a default under on_no_default="error" so the getter raises.
# Per-call context
result = client.get_string("my.key", contexts={"user": {"plan": "pro"}})
# Bound context (for request handlers etc.)
user_client = client.with_context({"user": {"id": "u123", "plan": "pro"}})
enabled = user_client.is_feature_enabled("my.flag")
# Scoped context: per thread and per asyncio task, restored on exit
with client.scoped_context({"user": {"id": "u123"}}):
enabled = client.is_feature_enabled("my.flag")
On a machine where someone has run qfg login, the SDK reads the signed-in
email from ~/.quonfig/tokens.json and adds it to every evaluation as
quonfig-user.email (so it also appears in telemetry example contexts); pass
enable_quonfig_user_context=False or set QUONFIG_DEV_CONTEXT=false to turn
this off.
from quonfig import Quonfig
client = Quonfig(
sdk_key="sdk-...",
logger_key="log-level.my-app", # config that drives per-logger rules
).init()
# Convenience form — SDK injects { "quonfig-sdk-logging": { "key": "my_app.auth" } }
# into context so a single config can route by logger path.
if client.should_log(logger_path="my_app.auth", desired_level="INFO"):
print("auth event")
# Primitive form — for callers that want explicit control over the config key.
# No auto-prefixing: pass the full stored key.
if client.should_log(config_key="log-level.my-app", desired_level="DEBUG"):
print("debug event")
logger_path is passed through verbatim — the SDK does not normalize it, so
callers can author config rules against whatever shape their host language
prefers (dotted, double-colon, slash, etc.).
loggingAttach QuonfigLoggerFilter to any logger or handler and the SDK will gate
records against logger_key. The record's name flows into context verbatim
as quonfig-sdk-logging.key, so a single config can drive per-logger rules.
import logging
from quonfig import Quonfig, QuonfigLoggerFilter
client = Quonfig(sdk_key="sdk-...", logger_key="log-level.my-app").init()
root = logging.getLogger()
root.addFilter(QuonfigLoggerFilter(client))
structlogQuonfigLoggerProcessor is a structlog processor. Place it after
structlog.stdlib.add_log_level so the level is populated on the event dict.
import structlog
from quonfig import Quonfig, QuonfigLoggerProcessor
client = Quonfig(sdk_key="sdk-...", logger_key="log-level.my-app").init()
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
QuonfigLoggerProcessor(client),
structlog.processors.JSONRenderer(),
],
)
structlog is an optional dependency — QuonfigLoggerProcessor raises
ImportError with an install hint if it isn't available. The stdlib filter
has no optional-dep concern.
import os
client = Quonfig(datadir="/path/to/workspace", environment="production")
client.init()
When you initialize the SDK with datadir="./path", configs are loaded once from
disk at init() time. Opt in to data_dir_auto_reload to have the SDK watch
the directory and re-read the envelope whenever files change — an editor save,
a git pull, or a build step.
from quonfig import Quonfig
def on_update():
print("Quonfig configs reloaded from disk")
client = Quonfig(
datadir="./workspace-data",
environment="development",
data_dir_auto_reload=True, # off by default — must be opted in
on_config_update=on_update,
)
client.init()
# Edit a file under ./workspace-data and on_update fires within ~200ms.
# On shutdown, close() stops the watcher and clears any pending debounce timer.
client.close()
git pull the datadir on a schedule.init() time) but
you're paying for nothing.Default is False; datadir mode is silent until you opt in.
git pull state, invalid JSON), the SDK logs the error and keeps
serving the previous envelope. on_config_update is not fired on parse
failure — only on a successful swap.git pull touching dozens of files) coalesce into a single re-read. Default
window: 200ms — long enough to absorb the 3–5 events typical editors emit
in <50ms, short enough that interactive edits feel immediate. Tune via
data_dir_auto_reload_debounce_ms if you need a different window.init().datadir to its real path at start time.
Editing the file the symlink points at is detected; atomic flips that
retarget the link itself are not.client.close() signals the watcher's stop event and joins the
daemon thread (≤2s). There is no separate handle to manage — the watcher
lifecycle is tied to the client. The thread is a daemon, so a stuck join
will not block process exit.WATCHFILES_FORCE_POLLING=1) is not a workaround: it trades the latency
for silent loss, permanently dropping a write that lands shortly after a
previously reported one. Production Linux is unaffected — inotify delivers
reliably, within the debounce window. If you are developing on a Mac and need
a change picked up immediately, restart the process (or construct and
init() a fresh client, which re-reads the datadir synchronously) rather
than waiting on the watcher — refresh() drives the HTTP config-fetch path
and does not re-read the datadir.Quonfig(
datadir="./workspace-data",
data_dir_auto_reload=True,
data_dir_auto_reload_debounce_ms=1000, # wait a full second after the last event
)
The default (200ms) is tuned for interactive editing. Raise it if you have a noisy producer (continuously regenerating files) and you'd rather see one reload per second than per save. Lower it only if you've measured that 200ms is meaningfully too slow for your use case.
See the open-source / local how-to for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
On a host that freezes the process between invocations, the SSE stream and the telemetry timers are dead weight — they can't run while the environment is frozen. Build the client once at module scope, turn the background channels off, and drive updates and telemetry from the handler instead.
import os
from quonfig import Quonfig
# Module scope — once per execution environment, on cold start.
client = Quonfig(
sdk_key=os.environ["QUONFIG_BACKEND_SDK_KEY"],
enable_sse=False, # no long-lived stream
fallback_poll_enabled=False, # no background poller
collect_evaluation_summaries=False, # no background telemetry
context_upload_mode="none", # no background telemetry
).init()
def lambda_handler(event, context):
client.update_if_staler_than(60_000) # non-blocking; returns immediately
body = client.get_string("greeting", default="hello")
client.flush() # no-op here (telemetry off); delivers it if enabled
return {"statusCode": 200, "body": body}
See the Lambdas / Serverless docs for the full walkthrough.
enable_sseDefaults to True — the historical behavior, so existing callers are
unaffected. Combined with fallback_poll_enabled it selects the update
channel:
True + fallback_poll_enabled=True (default): SSE is the primary channel;
the HTTP poller engages only when SSE fails.False + fallback_poll_enabled=True: no SSE client is constructed and the
HTTP poller becomes the primary channel, engaged immediately after init.False + fallback_poll_enabled=False: the client fetches once at init()
and then moves only via refresh() / update_if_staler_than().The chosen channel is logged once at init. With enable_sse=False there is no
stream to be connected to, so connection_state() is derived from the liveness
stamp alone — connected once a refresh has succeeded, and never
falling_back (poll-as-primary is the configured design, not a degraded
state).
update_if_staler_than(max_age_ms)Stale-while-revalidate, and non-blocking — it never puts a network round-trip on the request path.
max_age_ms: returns False, having done nothing.True immediately. The caller keeps serving the config already
in memory; a later call sees the fresher one.False. Refreshes are coalesced — at most one is
ever in flight, so a per-request caller can't stack threads against a slow or
unreachable upstream.True means "a refresh was triggered", not "a refresh completed". A worker
frozen mid-fetch completes on the next thaw; installing then is safe, because
the reject-older guard drops a payload that isn't newer than what the client
holds.
flush()Synchronously sends the pending telemetry window. The periodic timer that
normally delivers it doesn't fire while the environment is frozen, so anything
recorded during a request would sit in the collectors until the container is
recycled — and be lost. A no-op when telemetry is disabled, and it never raises.
If a POST is already in flight it waits for it first; after a failed POST it
respects the 30s resend floor and Retry-After (see Telemetry),
so it can return without sending. close() already sends the live window, so
this is only needed when the process outlives the request.
--preload, Celery, uWSGI, multiprocessing)No wiring required on POSIX. Importing quonfig installs a child-only
os.register_at_fork handler. After a fork, the child re-initializes on its
first use of the client, exactly like a newly constructed client: it fetches
its own config and starts its own threads. It does not evaluate from the
parent's snapshot.
# app.py, imported by `gunicorn --preload -w 8 app:app`
from quonfig import Quonfig
client = Quonfig(sdk_key="sdk-...").init() # built once, in the master
Each forked worker gets its own transport, its own SSE stream or HTTP poll loop, its own telemetry buffers, and fresh locks.
This covers every fork-based deployment:
| Host | Fork point |
|---|---|
Gunicorn --preload (sync, gthread; see gevent below) | master forks each worker |
Celery, default prefork pool | worker forks each child process |
uWSGI (without --lazy-apps) | master forks each worker |
multiprocessing / ProcessPoolExecutor with the fork start method | pool forks each process |
a bare os.fork() in application code | wherever you call it |
subprocess with preexec_fn= | CPython runs at-fork handlers in the pre-exec child |
The at-fork handler itself does no network I/O, starts no threads and takes no inherited lock. It only drops what the child inherited and marks the client for rebuild; the re-initialization runs on the child's first SDK call. Two consequences worth knowing:
multiprocessing workers doing unrelated work and the
pre-exec child of any subprocess spawn that passes preexec_fn.init_timeout_ms / on_init_failure rules. What
triggers the rebuild is a content read: get_*, is_feature_enabled, the
*_details getters, should_log, with_context and the values you read
through it, keys(), raw_config(), refresh(), update_if_staler_than()
and flush(). If you would rather pay that before your first request, call
init() (or any getter) in the worker's post-fork hook.connection_state(), ready(),
held_generation(), last_successful_refresh() and the other health
accessors answer the child's pre-start state — initializing, False, 0,
None — without starting a thread or making a request, until that child's
first content read. A liveness probe, a metrics scrape or a log line in a
forked worker therefore costs nothing and cannot stand a client up behind
your back.Details, if you need them:
fork inside a task, a master
that also serves) is unaffected.connection_state() tells the truth in a child. It reports
initializing — before the rebuild and after it, until the child's own
first refresh actually succeeds — rather than inheriting the parent's
connected.close() in a child before its first use is instant, and getters after it
return their defaults immediately.init()ed is not started behind your
back — but you can start it yourself. Construct it in the master and call
init() in the child (Gunicorn's post_fork, Celery's worker_process_init)
and you get a full client: its own transport, its own update channel, its own
telemetry.The spawn start method (the default for multiprocessing on macOS and
Windows) is unaffected: those children run a fresh interpreter and build their
own client from scratch, so there is nothing inherited to rebuild. On platforms
without os.fork the handler is never registered.
forkserver children are forks — of the forkserver process, not of your
main process — so the handler does run in them. It is a no-op unless a module
you preloaded via multiprocessing.set_forkserver_preload built a client;
normally the forkserver process holds none, so there is nothing to reset. Worth
knowing because Python 3.14 makes forkserver the default start method on
Linux.
CPython 3.12+ emits a DeprecationWarning when os.fork() is called in a
multi-threaded process, and the SDK's own background threads (SSE, fallback
poll, telemetry) are enough to trigger it. The warning is about fork-plus-
threads in general, not about this SDK — the child-only handler is what makes
the fork survivable, not what causes the warning.
Build the client after the fork under gevent, or do not use --preload.
Greenlets are not threads, and os.register_at_fork cannot help with them: if
the parent is monkey-patched, its SSE greenlet is still scheduled in the
child's copy of the hub and still reading the socket the parent is reading, so
the parent silently loses events (observed in 3 of 5 runs).
Gunicorn's own --preload -k gevent shape is fine as long as the master is
not monkey-patched — modern Gunicorn patches inside
GeventWorker.init_process(), which runs in the worker after the fork. If your
application calls monkey.patch_all() at import time under --preload, the
master is patched and you are in the broken shape. Build the client in
Gunicorn's post_fork hook instead.
Pass --enable-threads. uWSGI disables the Python threading machinery by
default, and the SDK's SSE stream, fallback poller and telemetry reporter are
all background threads — without that flag none of them run. (--lazy-apps
additionally sidesteps the fork by loading the app in each worker.)
Build the client after the fork on macOS. A forked child that calls the SDK on macOS can be killed by the platform, with or without this SDK:
requests resolves proxy settings through urllib.request.proxy_bypass ->
_scproxy -> SystemConfiguration, which enters the Objective-C runtime, and
libobjc deliberately aborts a child that does that after a fork from a
multi-threaded parent.NO_PROXY covering the API host (which keeps requests off
_scproxy), a child whose parent holds a live HTTPS connection at fork time
— which an initialized client always does, that is its SSE stream — dies
with SIGSEGV on its first request. A plain requests.get() in a forked
child reproduces this with Quonfig nowhere in the process (verified
2026-09-10, macOS 15 / CPython 3.11: 5 of 5 children).So NO_PROXY is not a reliable workaround on macOS. Because the rebuild is
lazy, a forked child that never calls the SDK never goes near any of this. If
your child does need the SDK on macOS, construct and init() the client in
the child (Gunicorn post_fork, Celery worker_process_init), not in the
master.
Datadir mode has the same shape of hazard: with data_dir_auto_reload=True the
child aborts (SIGABRT) when the rebuild starts its filesystem watcher, because
watchfiles reaches FSEvents through the same Objective-C runtime — build the
client after the fork there too.
Linux is unaffected — these are macOS system-library issues, and macOS is a development platform for this SDK, not a deployment one. The automatic rebuild is verified on Linux against a live api-delivery in the default SSE mode (parent and forked child both receive a value published after the fork).
The SDK sends usage telemetry to telemetry_url so the Quonfig dashboard can
show which flags and configs are evaluated and with what contexts. Telemetry
never affects flag evaluation: every failure below is contained in the
background reporter.
What is sent. Evaluation summaries (per flag/config: counts per rule and
value), context shapes (context field names and types), example contexts (up
to one per context key per hour) and failover counters. Opt out with
collect_evaluation_summaries=False and context_upload_mode="shapes_only"
(no example contexts) or "none" (no context data). With both off, no reporter
runs. The default context_upload_mode is "periodic_example".
How it is sent.
telemetry_flush_interval_ms (60s), with at most one POST in
flight. A tick that fires while a POST is still out is skipped and its data
rolls into the next window.telemetry_connect_timeout_ms (5s); each read
of the response by telemetry_timeout_ms (15s), so a server that never
answers is abandoned after 15s. (requests has no whole-request deadline, so
a server trickling bytes slower than that could hold a POST longer.)Retry-After (honored up to 10 minutes), oldest first, then the current
window. There is no immediate retry.telemetry_url is wrong: the SDK logs
one error and disables telemetry for the rest of the process. Any other 4xx
drops that one batch with an error (the server rejected the payload) and
telemetry continues.Logging (logger quonfig.telemetry). A failed POST logs at DEBUG only.
The first batch actually dropped logs one WARNING with the last POST result
and queue depth; further drops log at DEBUG with a summary WARNING at most
every 10 minutes; the first success after failures logs one INFO line.
flush(), close() and interpreter exit. flush() sends the current
window now (see flush()). close() sends the current window once
with a 5s deadline, does not resend kept batches, and never waits longer than
that. A client that is never closed gets the same final send from an atexit
hook: all live clients in parallel, 5s at most in total, so a hung telemetry
endpoint delays interpreter exit by at most 5s and never blocks it. The hook
only sends from the process that created the client: a forked child never
re-sends its parent's window.
Memory. Everything is bounded: at most 10,000 evaluation-summary keys, 10,000 context-shape fields and 10,000 example contexts per window (keys already recorded keep counting at the cap), a 100,000-entry example-context rate-limit map, and the 2MB retained queue.
| Option | Default |
|---|---|
telemetry_flush_interval_ms | 60_000 |
telemetry_timeout_ms | 15_000 |
telemetry_connect_timeout_ms | 5_000 |
telemetry_max_retained_batches | 5 |
telemetry_max_retained_bytes | 2_097_152 |
telemetry_max_retained_age_ms | 300_000 |
telemetry_max_evaluation_summaries | 10_000 |
telemetry_max_context_shape_fields | 10_000 |
telemetry_max_example_contexts | 10_000 |
None or a non-positive value means the default.
init() blocks until the first config loads, retrying for up to
init_timeout_ms (default 10s). If no config loads by then,
on_init_failure decides what happens:
on_init_failure | At the timeout | 401/403/404/410 from every leg |
|---|---|---|
"fail" (default) | raises QuonfigInitError (QuonfigInitTimeoutError if no leg answered) | raises at once (reason="unauthorized") |
"wait" | keeps blocking and retrying | logs an ERROR and keeps retrying |
"continue" | returns; getters serve code defaults | returns at once |
from quonfig import Quonfig, QuonfigInitError
client = Quonfig(sdk_key="sdk-...", on_init_failure="fail", init_timeout_ms=10_000)
try:
client.init()
except QuonfigInitError as e:
print(e.reason) # "unavailable" | "unauthorized" | "timeout"
raise
Getters never block on init. Before the first config they return your default
(or apply on_no_default). A client you keep after a caught
QuonfigInitError keeps retrying in the background and heals on its own:
check client.is_ready() or block on client.wait_for_ready(timeout_ms=...).
Under "wait" or "continue", wire your readiness probe to is_ready().
init_timeout_ms <= 0 runs exactly one fetch attempt (bounded by the per-leg
abort, config_fetch_hedge_abort_ms), then applies on_init_failure. It is
not "no deadline", and it never decides before that attempt completes.
The error's reason is "timeout" when the last attempt hung (every leg timed
out) and "unavailable" otherwise. A key rejected by only one leg is not
"unauthorized": every configured leg must answer 401, 403, 404 or 410.
Calling init() again on a client that already started (for example after a
caught QuonfigInitError) joins the running background retry. It starts no
second stream, and it returns at once if config has loaded since, or waits up
to init_timeout_ms and applies on_init_failure again.
Init log records carry event="quonfig.init.<name>" in extra. Init lines
and the error message name hosts and HTTP <status> only, never URL
credentials or response bodies.
The 1.x spellings "raise" (= "fail") and "return" /
"return_zero_value" (= "continue") still work and log a one-time
deprecation WARN.
A forked worker never serves the parent's config: it rebuilds its own snapshot
on first use and applies the same on_init_failure mode as a fresh boot (see
Forking servers).
If delivery is unreachable when a worker is recycled:
"fail" (default): the worker's first content read raises
QuonfigInitError, and later reads keep raising until delivery heals
(2.1.0+; 2.0.x served code defaults). is_feature_enabled never raises
(3.0.0+): it returns its default instead."wait": the worker's first request blocks until delivery is back."continue": the worker serves code defaults until it heals. Apps that
recycle workers often (low max_requests, a fork per job) should use this
and keep their defaults safe.If you call client.init() in Gunicorn's post_fork hook, wrap it in
try/except QuonfigInitError: an uncaught error there halts the master and
every worker.
# gunicorn.conf.py
from quonfig import QuonfigInitError
from app import client
def post_fork(server, worker):
try:
client.init()
except QuonfigInitError:
pass # the worker keeps retrying in the background
Details: Startup failure: forked workers.
| Param | Env var | Default |
|---|---|---|
sdk_key | QUONFIG_BACKEND_SDK_KEY | required for API mode |
api_urls | -- (derived from QUONFIG_DOMAIN) | ["https://primary.quonfig.com", "https://secondary.quonfig.com"] |
telemetry_url | -- (derived from QUONFIG_DOMAIN) | https://telemetry.quonfig.com |
environment | QUONFIG_ENVIRONMENT | "" |
datadir | QUONFIG_DIR | None |
init_timeout_ms | -- | 10_000 |
on_init_failure | -- | "fail" (see Init failure) |
on_no_default | -- | "error" |
logger_key | -- | None |
enable_sse | -- | True |
fallback_poll_enabled | -- | True |
data_dir_auto_reload | -- | False |
data_dir_auto_reload_debounce_ms | -- | 200 |
collect_evaluation_summaries | -- | True |
context_upload_mode | -- | "periodic_example" |
telemetry_* transport options | -- | see Telemetry |
QUONFIG_DOMAINA single env var governs the api, sse, and telemetry URL defaults:
| Env var | Default | Effect |
|---|---|---|
QUONFIG_DOMAIN | quonfig.com | Sets api_urls to https://primary.${DOMAIN} + https://secondary.${DOMAIN} and telemetry_url to https://telemetry.${DOMAIN}. SSE host is derived by prepending stream. to the api host. |
Resolution order (highest wins):
api_urls= / telemetry_url= kwargs (local-dev escape hatch).QUONFIG_DOMAIN env var.quonfig.com.The previously-supported QUONFIG_API_URL, QUONFIG_API_URLS, and
QUONFIG_TELEMETRY_URL env vars have been removed.
QUONFIG_DOMAINBy default the SDK derives every hostname from QUONFIG_DOMAIN (default
quonfig.com):
| Role | URL |
|---|---|
| Config fetch (primary) | https://primary.quonfig.com |
| SSE stream (primary) | https://stream.primary.quonfig.com |
| Config fetch (secondary) | https://secondary.quonfig.com |
| SSE stream (secondary) | https://stream.secondary.quonfig.com |
| Telemetry | https://telemetry.quonfig.com |
Set QUONFIG_DOMAIN to move all of them together (e.g.
QUONFIG_DOMAIN=quonfig-staging.com). Automatic failover and hedging between
the primary and the secondary are on by default — the secondary runs on
separate infrastructure, and the SDK fails over to it if the primary is
unreachable and hedges to it if the primary is slow.
An explicit api_urls= replaces the derived list wholesale. To keep automatic
failover with custom URLs, pass both a primary and a secondary URL:
client = Quonfig(
sdk_key="your-sdk-key",
api_urls=[
"https://primary.your-proxy.example",
"https://secondary.your-proxy.example",
],
)
A single URL disables failover, and the SDK logs a warning at init. See https://docs.quonfig.com/docs/explanations/architecture/resiliency for the full model.
The client exposes two diagnostic getters:
client.last_successful_refresh() # -> datetime | None
client.connection_state() # -> "connected" | "disconnected" | "falling_back" | "initializing"
last_successful_refresh() is the wall-clock time of the most recent
installed config envelope. Updated on every install path (datadir load,
initial HTTP fetch, SSE event, fallback poll). None before the first
install.connection_state() reports the SDK's current view of its delivery
pipeline. falling_back means SSE is down and the HTTP fallback poller
is engaged. (With enable_sse=False there is no stream to fall back
from, so an engaged poller reports connected — see the
enable_sse section.)Do not wire
last_successful_refresh()orconnection_state()directly into a Kubernetes liveness probe. These signals are diagnostic, not pass/fail. A liveness probe based on SDK freshness will amplify transient network blips into restart cascades.
Compose your own threshold (e.g. "alert if stale > 10 minutes AND state
is disconnected") rather than treating either primitive as binary
health.