Skip to content

A boolean flag satisfies a Float request in flagd-core: bool is a subclass of int #417

Open

Description

@aepfli

Summary

A boolean flag satisfies a Float request. FlagdCore.resolve_float_value on a boolean flag returns 1.0 with reason STATIC and no error code, where the specification requires the code default and TYPE_MISMATCH.

The application sees a plausible value and no indication that anything went wrong — the worst failure mode a feature flag has.

The sibling case for the integer accessor is already guarded here, and the equivalent bug in the SDK's own type check was open-feature/python-sdk#619, fixed by open-feature/python-sdk#621. This is the same Python quirk in the remaining direction.

Reproducing

from openfeature.contrib.tools.flagd.core.flagd_core import FlagdCore

core = FlagdCore()
core.set_flags(
    '{"flags":{"boolean-flag":{"state":"ENABLED",'
    '"variants":{"on":true,"off":false},"defaultVariant":"on"}}}'
)

print(core.resolve_float_value("boolean-flag", 0.1))
# value=1.0  reason=STATIC  error_code=None
# expected: value=0.1  reason=ERROR  error_code=TYPE_MISMATCH

core.resolve_integer_value("boolean-flag", 1)
# raises TypeMismatchError -- the integer path is already guarded

Observed output:

FLOAT   -> 1.0 | type float | reason STATIC | error_code None
INTEGER -> raised TypeMismatchError (the existing guard works)

Cause

Two things combine in tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/flagd_core.py.

The float entry in the type map is wider than the integer one (lines 25-26):

"integer": ((int,), "int"),
"float": ((int, float), "float"),

and the bool guard in _check_type is conditioned on the integer type alone (line 223):

# For integer type, reject bool (since bool is subclass of int)
if flag_type == "integer" and isinstance(value, bool):
    raise TypeMismatchError(...)

bool is a subclass of int, so isinstance(True, (int, float)) is True and a boolean passes the float check. resolve_float_value then widens it (lines 113-114):

result = self._resolve(flag_key, default_value, ctx, "float")
if isinstance(result.value, int):
    result.value = float(result.value)

isinstance(True, int) is True, so float(True) gives 1.0.

The SDK cannot catch this one

Worth stating, because the fix for open-feature/python-sdk#619 might look as though it covers this. It does not, and it does not need to:

  • For the integer accessor the SDK's map is a bare int, so isinstance(True, int) passed and a boolean slipped through. That was #619, now fixed.
  • For the float accessor the SDK's map is a bare float, and isinstance(True, float) is False — so the SDK rejects a boolean correctly on its own.

But by the time the value reaches the SDK it is a genuine float 1.0, because resolve_float_value already converted it. The evidence is destroyed upstream. Only this package can catch it.

Suggested fix

Widen the guard that is already there by one condition:

if flag_type in ("integer", "float") and isinstance(value, bool):
    raise TypeMismatchError(
        f"Expected type {type_name} but got {type(value).__name__}"
    )

The comment above it ("since bool is subclass of int") applies verbatim to the float mapping, which is (int, float).

Two things worth a regression test either way: that boolean-flag requested as a Float is a mismatch, and that integer-flag requested as a Float still succeeds and still returns 10.0 rather than 10 — the widening on lines 113-114 is deliberate and must survive the fix.

How it surfaced

Running the Python implementation of the cross-language provider conformance suite (open-feature/spec#417) against a real flagd backend, in-process resolver. The suite runs an identical type-mismatch matrix against every provider in every language, and the row boolean-flag requested as Float passes in Go, Java and JavaScript and fails only in Python — because only Python has bool as a subclass of int.

It affects the in-process resolver only; RPC type-asserts correctly on the same row, which is what localises it to this package rather than to the flagd daemon or to the provider.

That is the second time this one Python quirk has been found by the suite, in two packages and two accessor directions — #619 was the first. A Java or Go suite could not have caught either.

Activity

  1. aepfli commented on Sep 12, 2026

    @aepfli
    MemberAuthor

    One consequence worth flagging before a fix lands, because the obvious fix would cause a regression.

    The wide float entry is doing two jobs. Narrowing "float": ((int, float), "float") to ((float,), "float") would close the bool case, but it would also remove int→float widening, which is currently correct and load-bearing:

    integer-flag as Float  -> 10.0 (float), reason STATIC, no error code
    float-flag   as Integer -> TypeMismatchError
    

    That pair is exactly the rule flagd's numeric coercion ADR settles on (open-feature/flagd#1996), and what the provider conformance suite's @numeric-coercion capability asserts: coerce when lossless, fail when it would lose information. flagd-core satisfies it today.

    So the minimal fix is to leave the type map wide and extend the existing bool guard to the float path — the guard is conditioned on flag_type == "integer", and bool needs excluding from both. Narrowing the tuple instead would trade this bug for a conformance failure.


    Cross-language context, since it only became visible with four implementations side by side: whether this capability is available at all follows the language's JSON decoder rather than any choice the provider author makes.

    • Go's OFREP provider gets the lossless/lossy rule for free — encoding/json makes every JSON number a float64, so integer-ness never survives the wire and ResolveInt has to round-trip through int64, failing when lossy. It declares @numeric-coercion.
    • The Java and Python OFREP providers withhold it. Jackson maps a JSON integer to Integer and a fraction to Double; json.loads yields int and float. Both then admit a value only on an exact instance check, so nothing widens or narrows.

    Which means flagd-core and the OFREP provider in this repo answer the same question oppositely: ofrep/__init__.py maps FlagType.FLOAT to bare float and rejects an int, where flagd-core accepts one. Both are defensible given their decoders, but the divergence is worth knowing about when touching either type map.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions