Handling Missing Context Attributes Gracefully
This how-to is part of Context Enrichment Strategies for Targeting. When an enrichment source is slow or unavailable, attributes drop out of the evaluation context silently. The server-side SDK then hands the rule engine a partial map, and the rule — designed for a complete context — silently falls through to the default variant. Users see unexpected behavior; no exception fires.
Missing attributes rarely announce themselves. A rule checking tenantTier == "enterprise" on a context that lacks tenantTier evaluates to the default variant without raising an error. At scale, this reads as a mysterious targeting regression rather than a missing key. The steps below give you a detection layer, a runtime sanitizer, and a schema contract that together make the failure visible and recoverable.
Prerequisites
Step 1 — Detect missing attributes before they reach the SDK
Instrument the context at the enrichment boundary. Log a warning — and increment a metric — for every attribute that is absent or null. This converts a silent failure into an observable signal.
import { EvaluationContext } from '@openfeature/server-sdk';
const REQUIRED_KEYS: (keyof EvaluationContext)[] = [
'targetingKey', 'tenantId', 'region', 'environment',
];
function detectMissingAttributes(ctx: EvaluationContext): string[] {
return REQUIRED_KEYS.filter(
(k) => ctx[k] === undefined || ctx[k] === null || ctx[k] === '',
);
}
// In middleware, before the evaluation call:
const missing = detectMissingAttributes(context);
if (missing.length > 0) {
logger.warn('Missing targeting attributes', {
missing,
correlationId: context.correlationId,
});
metrics.increment('flag.context.missing_keys', missing.length);
}
Cross-link the warning log with the correlationId so you can trace exactly which request produced the gap. In the context enrichment pipeline, detection runs immediately after the enrich step and before the sanitize step.
Step 2 — Distinguish absent, null, and empty-string
The rule engine treats these three differently. Absent means the key does not exist in the map; null means it was explicitly set to nothing; "" (empty string) satisfies an equality check like tenantTier == "". Getting these mixed up breaks deterministic targeting.
type TargetingContext struct {
UserID string `json:"user_id"`
Tier *string `json:"tier,omitempty"` // pointer: nil = absent, "" = explicitly empty
}
var ctx TargetingContext
if err := json.Unmarshal(data, &ctx); err != nil {
return err
}
// nil pointer → key absent from map → rule evaluates to default variant
// empty-string pointer → key present as "" → equality rules fire on it
if ctx.Tier == nil {
defaultTier := "free"
ctx.Tier = &defaultTier // fill absent with safe default
}
Using pointer types for optional fields is the idiomatic Go approach. A plain string with omitempty collapses both absent and empty to the same "", which breaks == "pro" rules without any error.
Step 3 — Fill gaps with a sanitizer that applies safe defaults
A sanitizer runs after detection, merges defaults for any absent key, and passes the now-complete context to the SDK. The defaults must be explicitly documented — they determine the flag resolution behavior for every request that lost an attribute.
# Python — openfeature SDK
CONTEXT_DEFAULTS: dict = {
"region": "unknown",
"tenantTier": "free",
"betaOptIn": False,
"environment": "prod",
}
def sanitize_context(ctx: dict) -> dict:
"""Fill absent or None keys with safe defaults. ctx values win; defaults only fill gaps."""
filled = {**CONTEXT_DEFAULTS, **{k: v for k, v in ctx.items() if v is not None}}
valid_tiers = {"free", "pro", "enterprise"}
if filled.get("tenantTier") not in valid_tiers:
filled["tenantTier"] = "free" # reject invalid values too
return filled
safe_ctx = sanitize_context(raw_context)
result = client.get_boolean_value("api.search.semantic-rerank", False, safe_ctx)
Document each default value next to the flag definition in your flag taxonomy. Anyone reading the flag’s targeting rules should be able to tell what the sanitizer delivers when a source is absent.
Step 4 — Enforce a schema contract at the API or gateway layer
Runtime sanitizers are a safety net, not a primary defense. A JSON Schema contract enforced at the API gateway or service mesh ingress rejects malformed payloads before they reach enrichment, eliminating an entire class of missing-attribute bugs.
# context-schema.yaml — enforced at the API gateway
context_schema:
type: object
required:
- userId
- environment
properties:
userId:
type: string
pattern: "^u_[a-zA-Z0-9]+$"
environment:
type: string
enum: ["dev", "staging", "prod"]
tenantTier:
type: string
enum: ["free", "pro", "enterprise"]
default: "free"
region:
type: string
default: "us-east-1"
additionalProperties: false
Running this validation in CI — against synthetic contexts that exercise every absent-key path — proves that the sanitizer fills correctly and the schema rejects invalids before they ever reach production.
Verification
Run the sanitizer against a context that is missing every optional key and assert that it produces the expected defaults:
# flagd dry-run: minimal context — confirm defaults fire correct variant
curl -sf -X POST http://localhost:8013/schema.v1.Service/ResolveBoolean \
-H 'Content-Type: application/json' \
-d '{
"flagKey": "api.search.semantic-rerank",
"context": { "targetingKey": "anon-0001", "environment": "prod" }
}' | jq -e '.reason == "DEFAULT" or .reason == "TARGETING_MATCH"'
Also assert in your test suite that detectMissingAttributes fires a metric when tenantTier is absent, and that sanitize_context does not overwrite a value that is present.
Gotchas & Edge Cases
- Async race at session init: the enrichment call to the entitlement service may complete after the context is already serialized for the first in-flight request. Use
Promise.allSettledwith a timeout (see the enrichment pipeline guide) rather than awaiting sequentially. - Default ≠ safe for all flags: a
tenantTier: "free"default is safe for most rules but could grant unintended access to a flag that targetstenantTier != "enterprise"as an exclusion rule. Document per-flag safe defaults alongside the flag definition, not as a single global table. - Schema version drift: when the targeting context schema evolves, old enrichment services may omit new required fields. Version the schema and treat a missing required field from an older producer as a detection event, not a silent gap.
Troubleshooting & FAQ
Why does the flag return the default variant even though I can see the attribute in the request body?
The attribute was present in the HTTP body but was stripped at the API gateway, message broker, or by schema additionalProperties: false. Log the full serialized context at the SDK evaluation boundary — not earlier — to confirm what actually reached the rule engine.
How do I distinguish an intentional default from a silent fallback caused by a missing attribute?
Inspect the evaluation reason field in the SDK response. DEFAULT with no error code means the SDK used its code-supplied default; DEFAULT with errorCode: GENERAL or a missing key in the context usually means the rule fell through. Emit the reason as a metric label so you can graph intentional-default versus fallback-default rates separately.
Do I need the sanitizer if I already have JSON Schema validation at the gateway?
Yes, as a defense-in-depth layer. Gateway validation catches malformed inbound payloads; the sanitizer handles attributes that disappear inside your service — for example, an enrichment source that times out after the gateway already passed the request through. They protect different points in the pipeline.