Skip to content

fix(flags): treat an unparsable feature flag payload as no payload - #341

Merged
ioannisj merged 1 commit into
mainfrom
posthog/dotnet-guard-malformed-flag-payload
Oct 9, 2026
Merged

ioannisj merged 1 commit into
mainfrom
posthog/dotnet-guard-malformed-flag-payload

Conversation

@posthog

@posthog posthog Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Contract: get-feature-flag-payload — "Malformed serialized payloads return no payload" — and its bulk counterpart in get-feature-flags-and-payloads ("Bulk payload decoding agrees with single-flag APIs"). The same rule is referenced by evaluate-flags and get-feature-flag-result.

The spec requires that a serialized payload be validated as JSON before public access: malformed, empty, or whitespace-only input must be logged and treated as an absent payload, never surfaced raw and never thrown to the caller — and "a payload decoding failure MUST NOT discard the associated flag value, healthy sibling payloads, or the combined result."

The sdk-specs compliance matrix for this SDK records this as a single shared root behind four non-compliant rows (Evaluate Flags, Get Feature Flag Payload, Get Feature Flag Result, Get Feature Flags And Payloads):

An unguarded JsonDocument.Parse in both FeatureFlag factories means a malformed payload throws during flag construction — discarding the flag's correctly evaluated local value on one path and the entire remote response on the other. […] Replace both JsonDocument.Parse(...) calls in FeatureFlag.cs with a TryParsePayload helper that trims, rejects empty/whitespace-only input, logs once, and yields Payload = null — the same result the SDK already produces for a flag with no payload — so neither the flag's value nor its siblings are lost.

📝 What changed

  • FeatureFlag.CreateFromFlagsApi and FeatureFlag.CreateFromLocalEvaluation now decode payloads through a shared private ParsePayloadOrNull guard instead of calling JsonDocument.Parse directly. A null payload stays null as before; empty/whitespace-only input and a JsonException both log a warning and yield Payload = null.
  • Both factories take an optional ILogger?, threaded from the existing loggers at the three call sites (FlagsApiResultExtensions.ToFlagsResult, LocalEvaluator.EvaluateAllFlags, PostHogClient.GetFeatureFlagAsync), so the failure is attributable to a flag key in SDK diagnostics.

Before this change, one bad payload byte in a /flags response threw out of FlagsApiResult.ToFlagsResult's projection and EvaluateFlagsAsync's catch discarded the whole response — every healthy flag value and sibling payload with it — and nothing in the logs distinguished it from a transport failure. On the local-evaluation path the per-flag catch swallowed it, lost the flag's correctly evaluated value, and forced an unnecessary remote fallback.

✅ Why this is backwards-compatible

Behavior changes only for payloads that are already unusable. A payload that parses today parses identically; a payload that threw today now resolves to Payload = null, which is the SDK's existing representation for a flag with no payload, and which callers already have to handle. The raw malformed string is never exposed (JsonDocument? makes that impossible). No public API surface changes — both factories are internal, the new parameters are optional, and PublicAPI.*.txt is untouched.

💚 How did you test it?

  • New unit tests on both factories: {broken, "", and " " each yield Payload = null while Key, IsEnabled, and VariantKey are preserved.
  • New EvaluateFlagsAsync tests covering both evaluation paths with the same three inputs, mirroring the spec's scenario outlines: a malformed remote payload leaves the sibling flag's value and payload intact, and a malformed locally-evaluated payload no longer triggers a remote /flags fallback.
  • All 12 new cases were confirmed to fail against main's FeatureFlag.cs and pass with the fix.
  • Full CI flow locally: dotnet restore --locked-mode, bin/fmt --check, dotnet build --configuration Release, dotnet test --configuration Release — green on both net8.0 and netcoreapp3.1 (1235 / 1225 passed, 2 skipped, 0 failed).

➡️ Follow-up work (not in this PR)

The compliance notes list two further gaps on these rows that are deliberately out of scope here, to keep this change to the single contract:

  • No standalone GetFeatureFlagPayloadAsync(key, distinctId, matchValue?) API — the only routes to a payload still require an already-evaluated flag or snapshot.
  • No negative-knowledge retention for keys absent from both local definitions and a clean /flags response, so such keys re-probe on every call.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

Produced by the scheduled sdk-specs compliance run (Claude Code). The run reads the compliance matrices in PostHog/sdk-specs, ranks the open 🟡/❌ cells by safety, impact and scope, and implements exactly one per run. This cell was picked because its backwards-compatibility verdict is "Backward-compatible", it is user-facing (feature flag payload reads), and one guard resolves four matrix rows at a shared root. Breaking-verdict and Unknown cells are left for humans.

Scope decisions: the parse guard only, not the two follow-ups listed above, which are separate contracts/API additions. The logger is passed as an optional parameter rather than a static or ambient logger, to match how LocalEvaluator and PostHogClient already thread ILogger. Empty/whitespace input is reported with a distinct message from a parse failure so the two causes stay separable in logs.

Agent-authored — please review rather than rubber-stamp. No manual/sample-app testing was performed beyond the automated suite described above.


Created with PostHog Desktop

🤖 Generated with Claude Code

`FeatureFlag.CreateFromFlagsApi` and `CreateFromLocalEvaluation` both called
`JsonDocument.Parse` unguarded, so a malformed, empty, or whitespace-only
serialized payload threw during flag construction. On the local path that
discarded the flag's correctly evaluated value and forced a remote fallback;
on the remote path it discarded the entire `/flags` response.

Both factories now decode through a shared guard that logs a warning and
yields `Payload = null` — the same representation the SDK already uses for a
flag with no payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 0b375c97-1a50-41a9-bbb4-a7d134e77862
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

posthog-dotnet Compliance Report

Date: 2026-10-09T06:10:34.236760+00:00
Duration: 124965ms

✅ All Tests Passed!

47/47 tests passed


Capture Tests

✅ 30/30 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 256ms
Format Validation.Event Has Uuid ✅ 127ms
Format Validation.Event Has Lib Properties ✅ 110ms
Format Validation.Distinct Id Is String ✅ 115ms
Format Validation.Token Is Present ✅ 111ms
Format Validation.Custom Properties Preserved ✅ 117ms
Format Validation.Event Has Timestamp ✅ 111ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 120ms
Retry Behavior.Retries On 503 ✅ 8125ms
Retry Behavior.Does Not Retry On 400 ✅ 2117ms
Retry Behavior.Does Not Retry On 401 ✅ 2114ms
Retry Behavior.Respects Retry After Header ✅ 8120ms
Retry Behavior.Implements Backoff ✅ 22132ms
Retry Behavior.Retries On 500 ✅ 6117ms
Retry Behavior.Retries On 502 ✅ 6116ms
Retry Behavior.Retries On 504 ✅ 6117ms
Retry Behavior.Max Retries Respected ✅ 22119ms
Deduplication.Generates Unique Uuids ✅ 117ms
Deduplication.Preserves Uuid On Retry ✅ 6117ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 13126ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6121ms
Deduplication.No Duplicate Events In Batch ✅ 116ms
Deduplication.Different Events Have Different Uuids ✅ 111ms
Compression.Sends Gzip When Enabled ✅ 110ms
Batch Format.Uses Proper Batch Structure ✅ 109ms
Batch Format.Flush With No Events Sends Nothing ✅ 106ms
Batch Format.Multiple Events Batched Together ✅ 114ms
Error Handling.Does Not Retry On 403 ✅ 2112ms
Error Handling.Does Not Retry On 413 ✅ 2112ms
Error Handling.Retries On 408 ✅ 6117ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 147ms
Request Payload.Flags Request Uses V2 Query Param ✅ 109ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 108ms
Request Payload.Flags Request Omits Authorization Header ✅ 110ms
Request Payload.Token In Flags Body Matches Init ✅ 110ms
Request Payload.Groups Round Trip ✅ 113ms
Request Payload.Groups Default To Empty Object ✅ 110ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 107ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 110ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 110ms
Request Lifecycle.No Flags Request On Init Alone ✅ 3ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 109ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 213ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 112ms
Retry Behavior.Retries Flags On 502 ✅ 1113ms
Retry Behavior.Retries Flags On 504 ✅ 1111ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 212ms

@ioannisj ioannisj self-assigned this Oct 9, 2026
@ioannisj
ioannisj marked this pull request as ready for review October 9, 2026 11:04
@ioannisj
ioannisj requested a review from a team as a code owner October 9, 2026 11:04
@ioannisj
ioannisj merged commit 7e85789 into main Oct 9, 2026
20 checks passed
@ioannisj
ioannisj deleted the posthog/dotnet-guard-malformed-flag-payload branch October 9, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant