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.
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
_retention_policy_from_jsonfunction violates its type contract: it is annotated to returnint | Literal["infinite"]but returns raw JSON values typed asAny. Python'sjson.loads()can producefloatfor numbers with decimal points or scientific notation, meaning this function can returnfloat, violating its declared return type._retention_policy_from_jsonreturnsdata["age"]directly (typeAny) without converting/validating it is anint.float(e.g., JSON{ "age": 3600.0 }->3600.0). Expected: always returnint(or the literal string"infinite") per the function signature.isinstance(..., int)) can take the wrong branch. Existing tests can miss the issue because259200.0 == 259200isTrue.Code with Bug
src/s2_sdk/_mappers.pyExplanation
dataisdict[str, Any], sodata["age"]is unconstrained. When the source JSON uses a decimal or scientific notation (valid JSON),json.loads()produces a Pythonfloat, so_retention_policy_from_jsoncan return afloateven though it is annotated as returningint | "infinite".Codebase Inconsistency
The same mapper file explicitly coerces JSON numeric fields elsewhere:
src/s2_sdk/_mappers.pyThis suggests numeric fields from JSON are expected to be coerced to the annotated Python types, but retention policy age is not.
Recommended Fix
Convert
agetointin_retention_policy_from_json:Also convert
min_age_secssimilarly where assigned:History
This bug was introduced in commit 3dc9795. In the initial SDK implementation, the developer wrote the
_retention_policy_from_jsonfunction and thedelete_on_empty_min_ageassignment without wrapping the JSON values inint(), likely assuming that JSON deserialization would always return integers for theagefield. The function signature promisesint | Literal["infinite"]but returns the rawAny-typed dictionary value, creating the type contract violation from the start.