Skip to content

ZPA application segments: server_group_ids=None raises TypeError: 'NoneType' object is not iterable before any request is built #575

Description

@hackerboey

A PR is attached that fixes this — see Suggested fix below. Happy to
rescope it if you'd prefer a narrower or wider change.

Description

Every ZPA application segment resource reformats the caller's
server_group_ids into the wire-shaped serverGroups list behind a
key-presence test:

if "server_group_ids" in body:
    body["serverGroups"] = [{"id": group_id} for group_id in body.pop("server_group_ids")]

Presence is not the same as usability. server_group_ids=None puts the key in
body with a None value, so the comprehension raises

TypeError: 'NoneType' object is not iterable

inside the SDK, before the request is built and before anything is sent.

None is not an exotic input — it is what you get from forwarding an optional
keyword argument whose default is None, which is the natural shape of a
partial update that does not intend to change server groups:

def retarget(client, segment_id, domain, server_group_ids=None):
    return client.zpa.application_segment.update_segment(
        segment_id,
        domain_names=[domain],
        server_group_ids=server_group_ids,   # None unless the caller overrides it
    )

The practical consequence is that this partial update cannot be performed at
all
. To leave server groups untouched, the caller has to keep the key out of
the call entirely, which means building **kwargs conditionally rather than
just passing the argument through:

extra = {} if server_group_ids is None else {"server_group_ids": server_group_ids}
client.zpa.application_segment.update_segment(segment_id, domain_names=[domain], **extra)

That workaround has to be repeated at every call site, for a field the API
itself treats as optional on update.

Where

master at e7f5f7ef. Eleven occurrences, in both the create and update path
of each resource:

file line method
zscaler/zpa/application_segment.py 273-274 add_segment
zscaler/zpa/application_segment.py 408-409 update_segment
zscaler/zpa/application_segment.py 769-770 add_segment_provision
zscaler/zpa/app_segments_ba_v2.py 257-258 add_segment_ba
zscaler/zpa/app_segments_ba_v2.py 360-361 update_segment_ba
zscaler/zpa/app_segments_ba.py 275-276 add_segment_ba
zscaler/zpa/app_segments_ba.py 412-413 update_segment_ba
zscaler/zpa/app_segments_inspection.py 244-245 add_segment_inspection
zscaler/zpa/app_segments_inspection.py 396-397 update_segment_inspection
zscaler/zpa/app_segments_pra.py 252-253 add_segment_pra
zscaler/zpa/app_segments_pra.py 356-357 update_segment_pra

The update paths spell the loop variable gid and the create paths spell it
group_id; otherwise all eleven lines are identical.

Reproduction

No credentials, no tenant and no network access required — the failure happens
in the SDK's own body-building code, upstream of the request executor. The stub
below captures the body the SDK assembles and stops before any HTTP call.

# repro.py
from zscaler.zpa.application_segment import ApplicationSegmentAPI


class StubExecutor:
    def create_request(self, method, endpoint, body=None, headers=None, params=None, **kwargs):
        print("body the SDK built:", body)
        return None, "stopped before HTTP"


api = ApplicationSegmentAPI(StubExecutor(), {"client": {"customerId": "1234567890"}})

# A partial update that does not intend to change server groups. This is what
# forwarding an optional keyword argument whose default is None produces.
api.update_segment(
    "72058304855089379",
    domain_names=["app.example.com"],
    server_group_ids=None,
)
$ python repro.py
Traceback (most recent call last):
  File "repro.py", line 19, in <module>
    api.update_segment(
  File ".../zscaler/zpa/application_segment.py", line 409, in update_segment
    body["serverGroups"] = [{"id": group_id} for group_id in body.pop("server_group_ids")]
                                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: 'NoneType' object is not iterable

Swap ApplicationSegmentAPI.update_segment for any of the other ten entry
points in the table above and the traceback is the same modulo file and line.

Expected behavior

server_group_ids=None means "not supplied": no serverGroups key in the
request body, and the segment's existing server groups left untouched — the
same outcome as omitting the argument. The server_group_ids snake_case key
should also not leak into the body.

With the attached fix, the same repro prints:

$ python repro.py
body the SDK built: {'domain_names': ['app.example.com'], 'tcpPortRanges': [], 'tcpPortRange': [], 'udpPortRanges': [], 'udpPortRange': []}

Actual behavior

TypeError: 'NoneType' object is not iterable, raised from the SDK before a
request object exists. The traceback points at an SDK internal rather than at
anything the caller can act on, and no debug logs are produced because no
request is ever made.

Is it a regression?

Not that I can find — the presence test appears to predate the current v1.9.x
line, and all eleven sites are consistent with each other, so this reads as
original behavior rather than a recent change.

Note on the empty-list case

Worth calling out because it constrains the fix: an explicitly supplied empty
list is currently meaningful and must stay that way.

These methods deliberately omit serverGroups from the body when the caller
does not mention server_group_ids, so on an update [] is the only way to
express "clear the server groups". Compare the sibling port-range fields in the
same update paths, which are unconditionally reset — e.g.
app_segments_pra.py:408-411:

if "udp_port_ranges" in body:
    body["udpPortRanges"] = body.pop("udp_port_ranges")
else:
    body["udpPortRanges"] = []  # Explicitly clear if not provided

So [] already means "clear" in this code, and a fix must not collapse None
and [] together. A truthiness guard (if body.get("server_group_ids"):)
would do exactly that, which is why the attached PR tests for is not None.

Suggested fix

Pop the value first and reformat only when it is not None:

server_group_ids = body.pop("server_group_ids", None)
if server_group_ids is not None:
    body["serverGroups"] = [{"id": group_id} for group_id in server_group_ids]

Behavior per input:

server_group_ids before after
omitted no serverGroups unchanged
["72058304855090128"] serverGroups: [{"id": "72058304855090128"}] unchanged
[] serverGroups: [] unchanged
None TypeError no serverGroups, no leftover key

Unconditionally popping also keeps the snake_case key from surviving into the
request body, which matters for add_segment_provision — it uses
add_id_groups() rather than transform_common_id_fields(), and
add_id_groups() skips falsy values without popping them
(zscaler/utils.py:212-216), so a None left in body would be camel-cased
onto the wire as serverGroupIds: null.

The attached PR applies that three-line form at all eleven sites and adds
tests/unit/zpa/test_app_segment_server_group_ids.py, which parametrizes all
eleven entry points over the three inputs above. Eleven assertions fail on
e7f5f7ef and pass with the fix; the remaining twenty-two pass both before and
after, pinning the fact that nothing changes for callers who pass a real list
or an empty one.

Other information

  • SDK version: 1.9.44 (master, e7f5f7ef)
  • Python: 3.12
  • Found by reading the SDK source; reproduced locally with the stub above — no
    tenant, credentials or live API involved.

Related — same pattern, not covered by the attached PR

server_group_ids is the field I hit, so the PR is scoped to it to keep the
diff reviewable. The same presence-then-iterate shape appears on other fields
across zscaler/zpa/, and None should hit each of them the same way:

  • application_segment.py, app_segments_ba*.py, app_segments_inspection.py,
    app_segments_pra.py — tcp_port_range, udp_port_range
  • server_groups.py:215,218,291,294 — app_connector_group_ids, server_ids
  • service_edge_group.py:233,236,300,303 — trusted_network_ids,
    service_edge_ids
  • pra_credential_pool.py:231,286 — credential_ids
  • user_portal_link.py:200,261 — user_portal_link_ids
  • pra_approval.py:204,296 — application_ids; note these already pass a
    [] default to .pop(), which does not help when the key is present with
    a None value

Happy to follow up with a second PR covering those in the same shape, or to
widen the attached one, whichever you prefer.

Activity

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