Configuration Refresh Patterns
How to safely update feature flag configuration in production systems.
Overview
Configuration refresh is the act of replacing a namespace's current configuration with new configuration, typically loaded from a remote source. Konditional provides several patterns for safe configuration updates.
Refresh Strategies
1. Manual Refresh (Explicit Control)
Load configuration explicitly when you want to update:
object AppFeatures : Namespace("app")
// Initial load
when (val result = NamespaceSnapshotLoader(AppFeatures).load(initialConfig)) {
is ParseResult.Success -> logger.info("Initial config loaded")
is ParseResult.Failure -> logger.error("Initial load failed: ${result.error}")
}
// Later: manual refresh
fun refreshConfiguration() {
val newConfig = fetchFromRemote()
when (val result = NamespaceSnapshotLoader(AppFeatures).load(newConfig)) {
is ParseResult.Success -> logger.info("Config refreshed")
is ParseResult.Failure -> {
logger.error("Refresh failed: ${result.error}")
// Last-known-good remains active
}
}
}
Use when:
- You want explicit control over refresh timing
- Configuration changes are triggered by specific events (deployments, admin actions)
- You need to coordinate refresh with other operations
2. Polling Pattern (Periodic Refresh)
Check for configuration updates on a schedule:
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.minutes
class ConfigurationPoller(
private val namespace: Namespace,
private val fetchConfig: suspend () -> String,
private val pollInterval: Duration = 5.minutes
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun start() {
scope.launch {
while (isActive) {
try {
val config = fetchConfig()
when (val result = NamespaceSnapshotLoader(namespace).load(config)) {
is ParseResult.Success -> logger.info("Config updated via poll")
is ParseResult.Failure -> logger.warn("Poll config invalid: ${result.error}")
}
} catch (e: Exception) {
logger.error("Poll fetch failed", e)
}
delay(pollInterval)
}
}
}
fun stop() {
scope.cancel()
}
}
// Usage
val poller = ConfigurationPoller(
namespace = AppFeatures,
fetchConfig = { httpClient.get("https://config.example.com/app-features.json").body() }
)
poller.start()
Use when:
- Configuration changes are infrequent but need to be picked up eventually
- You want to avoid maintaining persistent connections
- Latency of several minutes is acceptable
Considerations:
- Balance poll frequency with load on config server
- Add jitter to avoid thundering herd
- Consider exponential backoff on failure
3. Webhook / Push Pattern (Event-Driven)
Receive notifications when configuration changes:
@POST("/config/webhook")
fun handleConfigUpdate(request: ConfigWebhookRequest): Response {
return when {
!request.isValidSignature() -> Response.status(401).build()
else -> {
val newConfig = fetchConfigFromCDN(request.configVersion)
when (val result = NamespaceSnapshotLoader(AppFeatures).load(newConfig)) {
is ParseResult.Success -> {
logger.info("Config updated via webhook")
Response.ok().build()
}
is ParseResult.Failure -> {
logger.error("Webhook config invalid: ${result.error}")
Response.status(400).build()
}
}
}
}
}
Use when:
- Configuration changes need to propagate quickly (seconds, not minutes)
- Your config service supports push notifications
- You can validate webhook authenticity (HMAC signatures, etc.)
Considerations:
- Implement webhook signature validation
- Handle webhook retry/failure scenarios
- Consider rate limiting to prevent abuse
4. File Watch Pattern (Local Development)
Watch a local file for changes:
import java.nio.file.*
import kotlin.io.path.readText
class ConfigFileWatcher(
private val namespace: Namespace,
private val configPath: Path
) {
private val watchService = FileSystems.getDefault().newWatchService()
fun start() {
configPath.parent.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY)
thread(start = true, name = "config-watcher") {
while (true) {
val key = watchService.take()
key.pollEvents().forEach { event ->
if ((event.context() as Path).fileName == configPath.fileName) {
reloadConfig()
}
}
key.reset()
}
}
}
private fun reloadConfig() {
try {
val config = configPath.readText()
when (val result = NamespaceSnapshotLoader(namespace).load(config)) {
is ParseResult.Success -> logger.info("Config reloaded from file")
is ParseResult.Failure -> logger.error("File config invalid: ${result.error}")
}
} catch (e: Exception) {
logger.error("Failed to read config file", e)
}
}
}
// Usage (development only)
if (environment == "development") {
ConfigFileWatcher(AppFeatures, Paths.get("config/features.json")).start()
}
Use when:
- Local development or testing
- Configuration is managed via local files
- You want instant feedback on configuration changes
Safety Guarantees
Atomic Replacement
Configuration updates are atomic—evaluation never sees partial state:
// Thread 1: Loading new config
NamespaceSnapshotLoader(AppFeatures).load(newConfig)
// Thread 2: Evaluating (concurrent)
val result = AppFeatures.someFeature.evaluate(ctx)
// Always sees either old config OR new config, never partial
Mechanism: load() atomically swaps the internal Configuration reference.
See Thread Safety for details.
Last-Known-Good on Failure
Invalid configuration is rejected; previous configuration remains active:
// Current config: { "maxRetries": 3 }
val badConfig = """{ "maxRetries": "invalid" }"""
when (val result = NamespaceSnapshotLoader(AppFeatures).load(badConfig)) {
is ParseResult.Failure -> {
// Load rejected, maxRetries still returns 3
logger.error("Bad config rejected: ${result.error}")
}
}
// Evaluation still works with last-known-good
val retries: Int = AppFeatures.maxRetries.evaluate(ctx) // Returns 3
Guarantee: Failed loads never affect evaluation.
Operational Patterns
Pattern: Initial Load + Polling Fallback
// 1. Load initial config synchronously at startup
val initialConfig = fetchConfigOrDefault()
when (val result = NamespaceSnapshotLoader(AppFeatures).load(initialConfig)) {
is ParseResult.Failure -> {
logger.error("Initial load failed, using defaults: ${result.error}")
// Defaults remain active
}
is ParseResult.Success -> logger.info("Initial config loaded")
}
// 2. Start polling for updates
ConfigurationPoller(AppFeatures, fetchConfig = ::fetchFromRemote).start()
Benefits:
- Service starts even if remote config unavailable
- Updates picked up automatically via polling
Pattern: Webhook + Polling Backup
// Primary: webhooks for fast updates
app.post("/config/webhook", ::handleConfigUpdate)
// Backup: polling in case webhooks fail
ConfigurationPoller(
namespace = AppFeatures,
fetchConfig = ::fetchFromRemote,
pollInterval = 15.minutes // Longer interval since webhooks handle most updates
).start()
Benefits:
- Fast updates via webhook (seconds)
- Resilient to webhook delivery failures
Pattern: Versioned Configuration with Rollback
data class VersionedConfig(
val version: String,
val config: String
)
class ConfigHistory(private val namespace: Namespace) {
private val history = mutableListOf<VersionedConfig>()
private val maxHistory = 10
fun loadAndTrack(
version: String,
config: String
): ParseResult {
return when (val result = NamespaceSnapshotLoader(namespace).load(config)) {
is ParseResult.Success -> {
history.add(0, VersionedConfig(version, config))
if (history.size > maxHistory) history.removeLast()
result
}
is ParseResult.Failure -> result
}
}
fun rollback(toVersion: String): ParseResult? {
val target = history.find { it.version == toVersion }
return target?.let { loadAndTrack(it.version, it.config) }
}
}
Use when:
- You need to quickly revert bad configuration
- Auditing configuration changes is required
Common Pitfalls
Pitfall: Ignoring ParseResult Failures
// DON'T
NamespaceSnapshotLoader(AppFeatures).load(config) // Ignored result
// DO
when (val result = NamespaceSnapshotLoader(AppFeatures).load(config)) {
is ParseResult.Success -> logger.info("Config loaded")
is ParseResult.Failure -> {
logger.error("Load failed: ${result.error}")
alertOps("Configuration load failure")
}
}
Pitfall: Polling Too Frequently
// DON'T
pollInterval = 10.seconds // High load on config server
// DO
pollInterval = 5.minutes // Reasonable for most use cases
// Use webhooks if you need faster propagation
Pitfall: No Webhook Authentication
// DON'T
@POST("/config/webhook")
fun update(config: String) {
NamespaceSnapshotLoader(AppFeatures).load(config) // Anyone can POST
}
// DO
@POST("/config/webhook")
fun update(
signature: String,
config: String
) {
if (!validateHMAC(signature, config, webhookSecret)) {
throw UnauthorizedException()
}
NamespaceSnapshotLoader(AppFeatures).load(config)
}
Monitoring Refresh Operations
Metrics to Track
- Refresh frequency: How often configuration is updated
- Parse failure rate: Percentage of loads that fail validation
- Refresh latency: Time from config change to application update
- Configuration version lag: Difference between expected and actual version
Example with Observability Hooks
AppFeatures.hooks.afterLoad.add { event ->
when (event.result) {
is ParseResult.Success -> {
metrics.increment("config.refresh.success")
metrics.gauge("config.version", event.version)
}
is ParseResult.Failure -> {
metrics.increment("config.refresh.failure")
logger.error("Config load failed", event.result.error)
}
}
metrics.recordLatency("config.refresh.duration", event.durationMs)
}
Summary
Konditional supports multiple refresh patterns:
- Manual: Explicit control, event-driven
- Polling: Periodic checks, simple implementation
- Webhook: Fast propagation, event-driven
- File watch: Development/testing
All patterns benefit from:
- Atomic updates: No partial state
- Last-known-good on failure: Invalid config rejected
- Thread-safe: Concurrent evaluation always safe
Choose the pattern that fits your:
- Latency requirements: Webhook (seconds) vs polling (minutes)
- Infrastructure: Push vs pull
- Complexity tolerance: Manual vs automated
Next Steps
- Thread Safety — How atomic updates work
- Failure Modes — Handling invalid configuration
- How-To: Safe Remote Configuration — Step-by-step pattern