Skip to content

Incident.severity deserialization crashes on the API's own null response #18

Description

@asweet-confluent

Summary

Incident.from_dict() raises TypeError: 'NoneType' object is not iterable whenever the API response contains "severity": null, which is the normal, expected value for any incident that has no severity set — including every kind: "scheduled" incident (Rootly's maintenance-window incidents never have a severity). This breaks create_incident.sync()/sync_detailed() (and by extension every other incidents endpoint that returns an Incident, e.g. get_incident, list_incidents, update_incident, cancel_incident, ...) for what is a completely ordinary API response, not an edge case.

Environment

  • Package: rootly 2.0.0 (PyPI), module rootly_sdk
  • Python: 3.13.3
  • Installed via uv (PEP 723 inline script dependency)

Steps to reproduce

from rootly_sdk import AuthenticatedClient
from rootly_sdk.api.incidents import create_incident
from rootly_sdk.models import NewIncident, NewIncidentData, NewIncidentDataAttributes

client = AuthenticatedClient(base_url="https://api.rootly.com", token="<API_TOKEN>")

body = NewIncident(
    data=NewIncidentData(
        type_="incidents",
        attributes=NewIncidentDataAttributes(title="repro", kind="scheduled"),
    )
)

create_incident.sync(client=client, body=body)

Any incident created without an explicit severity_id reproduces this — no special account config needed.

Actual result

The incident is created successfully server-side (HTTP 201), but the client raises before returning it:

Traceback (most recent call last):
  ...
  File ".../rootly_sdk/api/incidents/create_incident.py", line 37, in _parse_response
    response_201 = IncidentResponse.from_dict(response.json())
  File ".../rootly_sdk/models/incident_response.py", line 57, in from_dict
    data = IncidentResponseData.from_dict(d.pop("data"))
  File ".../rootly_sdk/models/incident_response_data.py", line 58, in from_dict
    attributes = Incident.from_dict(d.pop("attributes"))
  File ".../rootly_sdk/models/incident.py", line 1566, in from_dict
    severity = SeverityResponse.from_dict(_severity)
  File ".../rootly_sdk/models/severity_response.py", line 56, in from_dict
    d = dict(src_dict)
TypeError: 'NoneType' object is not iterable

Since the exception is raised inside _parse_response, there's no way to catch/handle it via the public sync_detailed()/Response API — it aborts before a Response is ever constructed. The created incident is left dangling with no id returned to the caller.

Expected result

create_incident.sync() returns an IncidentResponse with attributes.severity as UNSET (or None), matching how every other nullable relationship on Incident is already handled.

Root cause

Incident.severity is typed as Union[Unset, SeverityResponse] — it's missing None as a valid variant, even though the live API legitimately serializes this field as JSON null (confirmed via a raw HTTP call to the same endpoint; the severity key is present with value null, not omitted).

Incident.from_dict() only guards against the key being absent:

_severity = d.pop("severity", UNSET)
severity: Unset | SeverityResponse
if isinstance(_severity, Unset):
    severity = UNSET
else:
    severity = SeverityResponse.from_dict(_severity)

When the key is present with value null, _severity is None, isinstance(_severity, Unset) is False, and SeverityResponse.from_dict(None) is called, which does dict(None) internally and raises.

This is inconsistent with how the rest of the generated client handles nullable nested objects — most None-able object/relationship fields elsewhere in rootly_sdk use a _parse_x helper that checks if data is None: return data before delegating to the nested model's from_dict(). Incident.severity appears to be missing that guard, most likely because its OpenAPI schema wasn't marked nullable: true even though the API actually returns null for it.

Suggested fix

Either:

  1. Mark severity (and check for any sibling fields with the same shape) as nullable in the OpenAPI spec that generates this client, so the generator produces the standard _parse_x-with-None-guard pattern for it, or
  2. Patch Incident.from_dict() directly to guard the severity branch the same way other nullable object fields are already guarded:
_severity = d.pop("severity", UNSET)
severity: None | Unset | SeverityResponse
if isinstance(_severity, Unset) or _severity is None:
    severity = _severity
else:
    severity = SeverityResponse.from_dict(_severity)

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