Skip to content

[Detail Bug] Python SDK: Retention policy age deserialization can return float despite int type annotation #22

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_89d327b3-b883-4365-b6a3-46b6701342a9/bugs/bug_f5f36237-4f81-4241-9a44-a98f87ee2dbe

Introduced in #1 by @quettabit on Apr 7, 2026

Summary

  • Context: The _retention_policy_from_json function violates its type contract: it is annotated to return int | Literal["infinite"] but returns raw JSON values typed as Any. Python's json.loads() can produce float for numbers with decimal points or scientific notation, meaning this function can return float, violating its declared return type.
  • Bug: _retention_policy_from_json returns data["age"] directly (type Any) without converting/validating it is an int.
  • Actual vs. expected: Actual: may return a float (e.g., JSON { "age": 3600.0 } -> 3600.0). Expected: always return int (or the literal string "infinite") per the function signature.
  • Impact: Code that relies on the annotation (e.g., type guards like isinstance(..., int)) can take the wrong branch. Existing tests can miss the issue because 259200.0 == 259200 is True.

Code with Bug

src/s2_sdk/_mappers.py

def _retention_policy_from_json(data: dict[str, Any]) -> int | Literal["infinite"]:
    if "infinite" in data:
        return "infinite"
    return data["age"]  # <-- BUG 🔴 returns unvalidated Any; can be float from JSON

Explanation

data is dict[str, Any], so data["age"] is unconstrained. When the source JSON uses a decimal or scientific notation (valid JSON), json.loads() produces a Python float, so _retention_policy_from_json can return a float even though it is annotated as returning int | "infinite".

Codebase Inconsistency

The same mapper file explicitly coerces JSON numeric fields elsewhere:

src/s2_sdk/_mappers.py

values=[(int(v[0]), float(v[1])) for v in a["values"]]
values=[(int(v[0]), float(v[1])) for v in g["values"]]

This suggests numeric fields from JSON are expected to be coerced to the annotated Python types, but retention policy age is not.

Recommended Fix

Convert age to int in _retention_policy_from_json:

def _retention_policy_from_json(data: dict[str, Any]) -> int | Literal["infinite"]:
    if "infinite" in data:
        return "infinite"
    return int(data["age"])

Also convert min_age_secs similarly where assigned:

delete_on_empty_min_age = int(doe["min_age_secs"]) if doe else None

History

This bug was introduced in commit 3dc9795. In the initial SDK implementation, the developer wrote the _retention_policy_from_json function and the delete_on_empty_min_age assignment without wrapping the JSON values in int(), likely assuming that JSON deserialization would always return integers for the age field. The function signature promises int | Literal["infinite"] but returns the raw Any-typed dictionary value, creating the type contract violation from the start.

Metadata

Metadata

Assignees

Labels

detail-bugbug flagged by https://detail.dev/

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions