Detect Multi-status per-item failures in NdV1Strategy (#295) - #398
Detect Multi-status per-item failures in NdV1Strategy (#295)#398allenrobel wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes ND API v1 response handling so that success HTTP codes (notably 207 Multi-Status) are no longer treated as overall success when the response body reports per-item failures in DATA.results[] / DATA.switchIds[]. The change centralizes detection and error-message aggregation in NdV1Strategy, and removes redundant per-orchestrator workarounds (subinterface), with unit tests updated accordingly.
Changes:
- Update
NdV1Strategy.is_success()to returnFalsewhen any per-itemstatusinDATA.results[]/DATA.switchIds[]is a failure literal, and enhanceextract_error_message()to aggregate failing items. - Add unit tests covering both envelope shapes, failure literals, casing/whitespace normalization, and aggregation behavior.
- Remove redundant
_raise_on_multi_status_failuresguards from subinterface orchestrators and adjust downstream orchestrator tests to expect centralized messages.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
plugins/module_utils/rest/response_strategies/nd_v1_strategy.py |
Centralizes per-item failure detection and message aggregation for ND v1 responses. |
tests/unit/module_utils/test_response_handler_nd.py |
Adds focused unit coverage for multi-status per-item failure detection and messaging. |
plugins/module_utils/orchestrators/subinterface_unmanaged_interface.py |
Removes redundant per-item 207 detector now handled centrally. |
plugins/module_utils/orchestrators/subinterface_managed_interface.py |
Removes redundant per-item 207 detector now handled centrally. |
tests/unit/module_utils/orchestrators/test_subinterface_unmanaged_interface.py |
Drops direct tests of the removed guard and updates expectations to centralized failure messaging. |
tests/unit/module_utils/orchestrators/test_subinterface_managed_interface.py |
Drops direct tests of the removed guard and updates references to centralized detection. |
tests/unit/module_utils/orchestrators/test_manage_policy_group_orchestrator.py |
Updates assertions/documentation to reflect centralized handling for dict/switchIds envelope. |
tests/unit/module_utils/orchestrators/test_manage_acl.py |
Updates failure-message assertions now produced via the centralized strategy path. |
tests/unit/module_utils/orchestrators/test_l3out.py |
Updates assertions/documentation to reflect centralized per-item failure detection and surfaced messages. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address Copilot review feedback on #398. The detection is gated on any success code, not only 207 -- ND sends per-item statuses on plain HTTP 200 for some endpoints (the L3Out batch POST tests cover exactly that path) -- and the failure literal set includes "failure" alongside "failed"/"error". The docstrings and comments claimed 207-only and omitted "failure", describing behavior narrower than the code actually implements. Comments and docstrings only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9KvSKLMhYXnKUUoCszTVP
Addresses akinross' review of PR #398. Two of the findings land on code this PR only moved into _extract_dict_error_message; they are pre-existing on develop and fixed here since they sit on the path that reports an error to the user. Helpers: - _get_typed_value(mapping, key, expected_type, default) returns the value only when it is the expected type. dict.get(key, default) covers the absent key but returns None for an explicit JSON null, which is what made the messages/errors arrays crashable. - _first_non_empty(mapping, keys) walks candidate keys in priority order, skipping absent, None, empty, and whitespace-only values. Fixes: - Empty-string label: _format_multistatus_failure gated its label on `is not None`, so an item carrying name="" produced ": <message>". Both the label and the detail now run through _first_non_empty, so the two lines no longer disagree about truthiness. - Null messages/errors: `"messages" in data_dict and len(data_dict.get(...))` raised TypeError when ND sent the key with a null value. Both arrays now read through _get_typed_value, and the items carry an isinstance(dict) guard (`all(k in m ...)` against a string item was a silent substring check). - Dropped DATA.error text: is_success() classifies DATA.error as a failure, but the extractor had no matching branch, so the error ND actually sent was replaced by the generic "Request failed with status <code>" fallback. Per-item message keys are now the _MULTISTATUS_ITEM_MESSAGE_KEYS constant ("message", "warningMessage", "status"): ND is not consistent across endpoints, and fabric_update_group's attach items carry warningMessage. Those items ride an attachUpdateGroups envelope that the central path does not scan yet -- consolidating that is #397 -- but the message-key seam is in place. Six tests (01300-01350) cover the empty label, warningMessage detail, the no-label/no-detail generic literal, null messages, null errors, and DATA.error text. Full unit suite green (3158 passed); pylint 10.00/10, mypy/black/isort clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFbfgLeEKXi5g25NmJTSS
Address Copilot review feedback on #398. The detection is gated on any success code, not only 207 -- ND sends per-item statuses on plain HTTP 200 for some endpoints (the L3Out batch POST tests cover exactly that path) -- and the failure literal set includes "failure" alongside "failed"/"error". The docstrings and comments claimed 207-only and omitted "failure", describing behavior narrower than the code actually implements. Comments and docstrings only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9KvSKLMhYXnKUUoCszTVP
Addresses akinross' review of PR #398. Two of the findings land on code this PR only moved into _extract_dict_error_message; they are pre-existing on develop and fixed here since they sit on the path that reports an error to the user. Helpers: - _get_typed_value(mapping, key, expected_type, default) returns the value only when it is the expected type. dict.get(key, default) covers the absent key but returns None for an explicit JSON null, which is what made the messages/errors arrays crashable. - _first_non_empty(mapping, keys) walks candidate keys in priority order, skipping absent, None, empty, and whitespace-only values. Fixes: - Empty-string label: _format_multistatus_failure gated its label on `is not None`, so an item carrying name="" produced ": <message>". Both the label and the detail now run through _first_non_empty, so the two lines no longer disagree about truthiness. - Null messages/errors: `"messages" in data_dict and len(data_dict.get(...))` raised TypeError when ND sent the key with a null value. Both arrays now read through _get_typed_value, and the items carry an isinstance(dict) guard (`all(k in m ...)` against a string item was a silent substring check). - Dropped DATA.error text: is_success() classifies DATA.error as a failure, but the extractor had no matching branch, so the error ND actually sent was replaced by the generic "Request failed with status <code>" fallback. Per-item message keys are now the _MULTISTATUS_ITEM_MESSAGE_KEYS constant ("message", "warningMessage", "status"): ND is not consistent across endpoints, and fabric_update_group's attach items carry warningMessage. Those items ride an attachUpdateGroups envelope that the central path does not scan yet -- consolidating that is #397 -- but the message-key seam is in place. Six tests (01300-01350) cover the empty label, warningMessage detail, the no-label/no-detail generic literal, null messages, null errors, and DATA.error text. Full unit suite green (3158 passed); pylint 10.00/10, mypy/black/isort clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFbfgLeEKXi5g25NmJTSS
2dd6808 to
6556d90
Compare
|
@allenrobel Confirmed contract: bulk create (POST /links) and bulk delete (POST /linkActions/remove) both return HTTP 207 with a body shaped {"links": [{"linkId", "message", "status"}]}, where status is either success or failure. Both scopes (manage and one_manage) use the same shape. What is needed:
Optional but actually nice: instead of every orchestrator editing these three module level constants, consider letting an orchestrator declare its own result keys and label keys (for example a bulk_result_keys and bulk_label_keys attribute the strategy reads). Then links, l3out, acl and policy_group can each register their envelope without touching the shared file. Once items 1 and 2 land and the nesting question in 4 is settled, I will delete _raise_on_bulk_failures in nd_manage_links and rely on the central detector. Until then I am keeping the bespoke scan as the safe superset, with a TODO(4.2.1) marker pointing here. Happy to send links response fixtures once the shape is wired in. |
ND rejects mixed-policyType bulk creates (207 with a failed item and nothing created) and the module trusted the 207, reporting changed while creating nothing - surfaced by the XE integration idempotency test. Group one POST per (switch, policyType) and fail on any results[] item whose status is not exactly 'success'. Scoped to the loopback orchestrator; PR #398's NdV1Strategy 207 support supersedes the check. Vault: bulk-interface-create-rejects-mixed-policy-types, multi-status-207-status-field-inconsistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ND rejects mixed-policyType bulk creates (207 with a failed item and nothing created) and the module trusted the 207, reporting changed while creating nothing - surfaced by the XE integration idempotency test. Group one POST per (switch, policyType) and fail on any results[] item whose status is not exactly 'success'. Scoped to the loopback orchestrator; PR #398's NdV1Strategy 207 support supersedes the check. Vault: bulk-interface-create-rejects-mixed-policy-types, multi-status-207-status-field-inconsistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address Copilot review feedback on #398. The detection is gated on any success code, not only 207 -- ND sends per-item statuses on plain HTTP 200 for some endpoints (the L3Out batch POST tests cover exactly that path) -- and the failure literal set includes "failure" alongside "failed"/"error". The docstrings and comments claimed 207-only and omitted "failure", describing behavior narrower than the code actually implements. Comments and docstrings only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9KvSKLMhYXnKUUoCszTVP
Addresses akinross' review of PR #398. Two of the findings land on code this PR only moved into _extract_dict_error_message; they are pre-existing on develop and fixed here since they sit on the path that reports an error to the user. Helpers: - _get_typed_value(mapping, key, expected_type, default) returns the value only when it is the expected type. dict.get(key, default) covers the absent key but returns None for an explicit JSON null, which is what made the messages/errors arrays crashable. - _first_non_empty(mapping, keys) walks candidate keys in priority order, skipping absent, None, empty, and whitespace-only values. Fixes: - Empty-string label: _format_multistatus_failure gated its label on `is not None`, so an item carrying name="" produced ": <message>". Both the label and the detail now run through _first_non_empty, so the two lines no longer disagree about truthiness. - Null messages/errors: `"messages" in data_dict and len(data_dict.get(...))` raised TypeError when ND sent the key with a null value. Both arrays now read through _get_typed_value, and the items carry an isinstance(dict) guard (`all(k in m ...)` against a string item was a silent substring check). - Dropped DATA.error text: is_success() classifies DATA.error as a failure, but the extractor had no matching branch, so the error ND actually sent was replaced by the generic "Request failed with status <code>" fallback. Per-item message keys are now the _MULTISTATUS_ITEM_MESSAGE_KEYS constant ("message", "warningMessage", "status"): ND is not consistent across endpoints, and fabric_update_group's attach items carry warningMessage. Those items ride an attachUpdateGroups envelope that the central path does not scan yet -- consolidating that is #397 -- but the message-key seam is in place. Six tests (01300-01350) cover the empty label, warningMessage detail, the no-label/no-detail generic literal, null messages, null errors, and DATA.error text. Full unit suite green (3158 passed); pylint 10.00/10, mypy/black/isort clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFbfgLeEKXi5g25NmJTSS
|
@shrsr Items 1 and 2 are in — commit "Scan links[] Multi-Status envelope; label by linkId (PR #398 review)". On item 4, the nesting question: the detector's read of Related false-positive check: On the Links response fixtures are welcome once you switch the module over — with the above landed, you should be able to drop |
ND rejects mixed-policyType bulk creates (207 with a failed item and nothing created) and the module trusted the 207, reporting changed while creating nothing - surfaced by the XE integration idempotency test. Group one POST per (switch, policyType) and fail on any results[] item whose status is not exactly 'success'. Scoped to the loopback orchestrator; PR #398's NdV1Strategy 207 support supersedes the check. Vault: bulk-interface-create-rejects-mixed-policy-types, multi-status-207-status-field-inconsistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address Copilot review feedback on #398. The detection is gated on any success code, not only 207 -- ND sends per-item statuses on plain HTTP 200 for some endpoints (the L3Out batch POST tests cover exactly that path) -- and the failure literal set includes "failure" alongside "failed"/"error". The docstrings and comments claimed 207-only and omitted "failure", describing behavior narrower than the code actually implements. Comments and docstrings only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9KvSKLMhYXnKUUoCszTVP
Addresses akinross' review of PR #398. Two of the findings land on code this PR only moved into _extract_dict_error_message; they are pre-existing on develop and fixed here since they sit on the path that reports an error to the user. Helpers: - _get_typed_value(mapping, key, expected_type, default) returns the value only when it is the expected type. dict.get(key, default) covers the absent key but returns None for an explicit JSON null, which is what made the messages/errors arrays crashable. - _first_non_empty(mapping, keys) walks candidate keys in priority order, skipping absent, None, empty, and whitespace-only values. Fixes: - Empty-string label: _format_multistatus_failure gated its label on `is not None`, so an item carrying name="" produced ": <message>". Both the label and the detail now run through _first_non_empty, so the two lines no longer disagree about truthiness. - Null messages/errors: `"messages" in data_dict and len(data_dict.get(...))` raised TypeError when ND sent the key with a null value. Both arrays now read through _get_typed_value, and the items carry an isinstance(dict) guard (`all(k in m ...)` against a string item was a silent substring check). - Dropped DATA.error text: is_success() classifies DATA.error as a failure, but the extractor had no matching branch, so the error ND actually sent was replaced by the generic "Request failed with status <code>" fallback. Per-item message keys are now the _MULTISTATUS_ITEM_MESSAGE_KEYS constant ("message", "warningMessage", "status"): ND is not consistent across endpoints, and fabric_update_group's attach items carry warningMessage. Those items ride an attachUpdateGroups envelope that the central path does not scan yet -- consolidating that is #397 -- but the message-key seam is in place. Six tests (01300-01350) cover the empty label, warningMessage detail, the no-label/no-detail generic literal, null messages, null errors, and DATA.error text. Full unit suite green (3158 passed); pylint 10.00/10, mypy/black/isort clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFbfgLeEKXi5g25NmJTSS
772c3f2 to
ac2baef
Compare
Requested by @shrsr for nd_manage_links: bulk link create (POST /links) and bulk link delete (POST /linkActions/remove) return HTTP 207 with {"links": [{"linkId", "message", "status"}]}, status success|failure (verified against the ND 4.2.1 OpenAPI). Add "links" to _MULTISTATUS_ITEM_KEYS and "linkId" to _MULTISTATUS_ITEM_LABEL_KEYS (ahead of the generic "id") so the central detector catches per-link failures and labels them by link id. The GET /links list body rides the same links envelope, but its link objects carry no top-level status key, so the literal-gated scan cannot false-positive on queries (test 01390). The bulk-create OpenAPI example carries linkId="" on a failing item; _first_non_empty already skips empty labels (test 01380). Four new tests (01360-01390). Full unit suite green: 3182 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHGFDqqU37PRqNHhKw1FCQ
ND returns HTTP 207 for batch operations and reports per-item outcomes in DATA.results[]/DATA.switchIds[] items carrying status success|failed| error. NdV1Strategy previously classified any 207 as success, so per-item failures were silently reported as success/changed, masking failures and breaking idempotency. is_success() now returns False when any per-item status is failed/failure/ error (case-insensitive, whitespace-tolerant, scanned regardless of the success code); extract_error_message() aggregates the failing items as "label: message". The dict-error handling is factored into a helper to keep branch count in bounds. Also updates five downstream orchestrator tests (manage_l3out, manage_acl, manage_policy_group) whose bespoke per-item messages the centralized path now preempts for hard-failure literals. Behavior (raise) is preserved; the bespoke detectors remain and still cover allowlist/soft-status cases the baseline does not (tracked for consolidation in #397). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAFsW8ALcvgZBSbgKjRPxn
With centralized 207 per-item failure handling now in NdV1Strategy, the per-orchestrator _raise_on_multi_status_failures guards in the managed and unmanaged subinterface orchestrators are redundant: _request() already raises via the strategy when the 207 body reports a failed/error item. Remove both guards and their TODO(4.2.1) markers (the managed marker was unslugged; the unmanaged one was multi-status-207-status-field-inconsistent, whose vault note stays valid as the centralized code accommodates the same ND status-field inconsistency). Remove the now-redundant direct-call unit tests (equivalent coverage lives in test_response_handler_nd.py) and keep the end-to-end 207 create tests, re-pointed to the centralized message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAFsW8ALcvgZBSbgKjRPxn
Address Copilot review feedback on #398. The detection is gated on any success code, not only 207 -- ND sends per-item statuses on plain HTTP 200 for some endpoints (the L3Out batch POST tests cover exactly that path) -- and the failure literal set includes "failure" alongside "failed"/"error". The docstrings and comments claimed 207-only and omitted "failure", describing behavior narrower than the code actually implements. Comments and docstrings only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9KvSKLMhYXnKUUoCszTVP
Addresses akinross' review of PR #398. Two of the findings land on code this PR only moved into _extract_dict_error_message; they are pre-existing on develop and fixed here since they sit on the path that reports an error to the user. Helpers: - _get_typed_value(mapping, key, expected_type, default) returns the value only when it is the expected type. dict.get(key, default) covers the absent key but returns None for an explicit JSON null, which is what made the messages/errors arrays crashable. - _first_non_empty(mapping, keys) walks candidate keys in priority order, skipping absent, None, empty, and whitespace-only values. Fixes: - Empty-string label: _format_multistatus_failure gated its label on `is not None`, so an item carrying name="" produced ": <message>". Both the label and the detail now run through _first_non_empty, so the two lines no longer disagree about truthiness. - Null messages/errors: `"messages" in data_dict and len(data_dict.get(...))` raised TypeError when ND sent the key with a null value. Both arrays now read through _get_typed_value, and the items carry an isinstance(dict) guard (`all(k in m ...)` against a string item was a silent substring check). - Dropped DATA.error text: is_success() classifies DATA.error as a failure, but the extractor had no matching branch, so the error ND actually sent was replaced by the generic "Request failed with status <code>" fallback. Per-item message keys are now the _MULTISTATUS_ITEM_MESSAGE_KEYS constant ("message", "warningMessage", "status"): ND is not consistent across endpoints, and fabric_update_group's attach items carry warningMessage. Those items ride an attachUpdateGroups envelope that the central path does not scan yet -- consolidating that is #397 -- but the message-key seam is in place. Six tests (01300-01350) cover the empty label, warningMessage detail, the no-label/no-detail generic literal, null messages, null errors, and DATA.error text. Full unit suite green (3158 passed); pylint 10.00/10, mypy/black/isort clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFbfgLeEKXi5g25NmJTSS
Requested by @shrsr for nd_manage_links: bulk link create (POST /links) and bulk link delete (POST /linkActions/remove) return HTTP 207 with {"links": [{"linkId", "message", "status"}]}, status success|failure (verified against the ND 4.2.1 OpenAPI). Add "links" to _MULTISTATUS_ITEM_KEYS and "linkId" to _MULTISTATUS_ITEM_LABEL_KEYS (ahead of the generic "id") so the central detector catches per-link failures and labels them by link id. The GET /links list body rides the same links envelope, but its link objects carry no top-level status key, so the literal-gated scan cannot false-positive on queries (test 01390). The bulk-create OpenAPI example carries linkId="" on a failing item; _first_non_empty already skips empty labels (test 01380). Four new tests (01360-01390). Full unit suite green: 3182 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHGFDqqU37PRqNHhKw1FCQ
NdV1Strategy now marks a request failed when a Multi-Status body reports per-item failures, so _request() raises before the orchestrator-level checks ever run. Remove the now-unreachable _raise_on_bulk_errors (manage_route_map) and _raise_on_207_failures (manage_prefix_list), matching the subinterface orchestrator cleanup already in this branch, and update the four unit tests that asserted the old orchestrator-level error messages to expect the strategy-level "ND Error: <name>: <message>" detail instead. These two modules landed on develop (#286, #288) after this branch's approach was set, which is why they were missed in the original sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnSktMFZGRafkf5GbuBwwm
Same rationale as the route-map/prefix-list cleanup: NdV1Strategy fails the request on per-item Multi-Status errors before _raise_on_207_failures could run. The acl tests were already adapted to the strategy-level messages earlier in this branch, so only the orchestrator changes here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnSktMFZGRafkf5GbuBwwm
ND rejects mixed-policyType bulk creates (207 with a failed item and nothing created) and the module trusted the 207, reporting changed while creating nothing - surfaced by the XE integration idempotency test. Group one POST per (switch, policyType) and fail on any results[] item whose status is not exactly 'success'. Scoped to the loopback orchestrator; PR #398's NdV1Strategy 207 support supersedes the check. Vault: bulk-interface-create-rejects-mixed-policy-types, multi-status-207-status-field-inconsistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
39f2eb9 to
5e31239
Compare
| # indicate every item succeeded -- ND sends these bodies on 207 and, for some endpoints, | ||
| # on plain 200 -- so any success-code response with a failing item must not be | ||
| # classified as success. See issue #295. | ||
| if _failed_multistatus_items(response): |
There was a problem hiding this comment.
High: Terminal per-item failures replay the complete mutation for up to 300 seconds
Issue
The new item-status scan converts a success-code 200/207 response containing a
failed member into success=False, but RestSend treats every unsuccessful
result as retryable and resubmits the original mutation until its 300-second
budget expires.
Evidence
nd_v1_strategy.pyline 270
changes a success-code response containing a failed item into an unsuccessful
response-handler result.rest_send.pylines 330-381
retries the same method, path, and payload whenever the result is
unsuccessful; the default budget is 300 seconds.- An exact-head reproduction using a mixed HTTP 207 response, a three-second
timeout, and a one-second send interval submitted the identical POST three
times before returning the deterministic application failure.
Live ND 4.2 verification
A bounded live test ran against Nexus Dashboard 4.2.1 build 4.2.1.10 using
the exact PR head. The test pre-created ACL reg_pr398_seed_205203, then sent
one batch containing that existing ACL and new ACL
reg_pr398_new_205203.
| Submission | New ACL result | Existing ACL result | HTTP |
|---|---|---|---|
| 1 | success: created successfully |
failed: already exists |
207 |
| 2 | failed: already exists |
failed: already exists |
207 |
| 3 | failed: already exists |
failed: already exists |
207 |
All three requests used the identical payload, with SHA-256
3352085c889e2d619b2fc1893f6f8e86c53c3ca003483714bce7903a858b35b3.
The first request created the new ACL, but the existing-ACL failure made the
aggregate result unsuccessful. RestSend then replayed the complete payload
twice, causing the ACL that had just succeeded to fail as already existing.
The live probe was deliberately bounded to a six-second timeout, a two-second
send interval, and three submissions. The production defaults in RestSend
are 300 seconds and five seconds, so the same loop can submit approximately 60
times; that count is derived from the source defaults rather than a
five-minute live run. The final result was success=False, changed=False
despite the first request changing controller state.
The temporary ACLs and fabric were deleted successfully after the test, the
fabric deletion was confirmed by HTTP 404, and the postflight ownership audit
passed with no designated switches managed by the test controller.
Existing PR overlap
No matching existing PR comment found. Existing threads cover supported
envelopes, labels, null handling, error text, and refactoring, but not transport
retry ownership.
Existing open issue overlap
No matching open issue found. Issues
#295 and
#397 track per-item
failure detection and detector consolidation, not replay of a terminal
application failure. Issue
#396 tracks nd_rest
accepting successful HTTP 207 responses, not retry classification. Issue
#456 lists safe 207
handling as a dependency of a future interface workflow; it does not track the
shared RestSend fix.
Impact
A deterministic validation failure can resubmit a non-idempotent batch
mutation up to roughly 60 times. Members that already succeeded are sent again,
which can duplicate side effects, produce misleading already-exists failures,
replace the original diagnostic, and multiply controller load. Orchestrators
with their own outer retries can amplify the request count further.
Suggested fix
Add an explicit terminal-versus-retryable classification between response
handling and RestSend. A successful HTTP status containing a definitive
per-item application failure should stop after one request, while
orchestrator-specific eventual-consistency policies may retain bounded retries.
Add a production-mode regression test with timeout > send_interval that
asserts exactly one submission for mixed 200/207 failures, plus an outer-retry
test for L3Out.
There was a problem hiding this comment.
Confirmed — the live ACL repro made this unambiguous. Fixed in "Classify terminal vs retryable mutation failures in ResponseHandler (#295)" and "Stop RestSend retrying terminal application failures (#295)": the response handler now marks every POST/PUT/DELETE result with retryable, where a failure whose RETURN_CODE is a success code (the application definitively rejected the request) is terminal, and RestSend._commit_normal_mode() breaks after one submission on retryable=False. Terminal scope is any embedded error on a success code, so the pre-existing DATA.error/ERROR-on-200 replay is fixed by the same seam. Non-success codes (5xx) keep today's bounded retries, GET semantics are untouched (no retryable key, default-retryable), and orchestrator-level eventual-consistency policies still work — the L3Out attach "not found" outer retry is pinned by test_l3out_00990. Regression tests: test_rest_send_01100 asserts exactly one submission with timeout > send_interval (a sentinel second response turns any replay into a visible failure), test_rest_send_01110 guards that 5xx still retries, and test_l3out_00980 covers the outer-retry amplification you raised.
| # indicate every item succeeded -- ND sends these bodies on 207 and, for some endpoints, | ||
| # on plain 200 -- so any success-code response with a failing item must not be | ||
| # classified as success. See issue #295. | ||
| if _failed_multistatus_items(response): |
There was a problem hiding this comment.
Medium: Mixed partial success is reported as changed=False
Issue
The response handler couples aggregate success to changed state. When one batch
member succeeds and another fails, the new scan correctly makes the request
unsuccessful but the failure branch reports changed=False even though Nexus
Dashboard changed.
Evidence
nd_v1_strategy.pyline 270
makes a mixed success/failure item response unsuccessful.response_handler_nd.pylines 220-232
hard-codeschanged=Falsewhenever aggregate success is false.- The exact-head mixed-result reproduction returned
success=False, changed=Falseafter one item succeeded and one failed.
Live ND 4.2 verification
A bounded live test against Nexus Dashboard 4.2.1 build 4.2.1.10 used the
exact PR head to send a batch containing one new ACL and one ACL that already
existed. The first HTTP 207 response reported:
| Batch member | Controller result |
|---|---|
New ACL reg_pr398_new_205203 |
success: created successfully |
Existing ACL reg_pr398_seed_205203 |
failed: already exists |
The next two submissions reported that both ACLs already existed, confirming
that the successful member from the first response had changed persistent
controller state. Nevertheless, the final RestSend result was
success=False, changed=False.
Existing PR overlap
No matching existing PR comment found. The separately posted High finding
reports the observed changed=False value as evidence of complete-payload
replay, but it does not identify or propose a fix for inaccurate partial-change
reporting.
Existing open issue overlap
No matching open issue found. Issue
#295 tracks recognizing
failed members, not truthful partial-change reporting. Issue
#389 tracks
NDStateMachine collection synchronization after mid-run failures, not the
shared response handler's changed result for one mixed 207 batch. Issues
#385 and
#456 discuss final-state
or workflow behavior but do not track this shared response-handler correction.
Impact
Failed Ansible output can claim that nothing changed even though successful
members were created, deleted, or deployed. Recovery automation and operators
cannot trust the result, and a retry begins from unexpectedly modified
controller state.
Suggested fix
Track mutation independently from aggregate success. For recognized itemized
responses, report changed=True when any member succeeded and
changed=False only when every member failed, honoring the controller modified
indicator where available. Preserve both successful and failed member evidence,
and add mixed-success and all-failed tests through ResponseHandler,
RestSend, and at least one module path.
There was a problem hiding this comment.
Confirmed. Fixed in "Report changed=True for mixed Multi-Status batches (#295)": the failure branch of ResponseHandler._handle_post_put_delete_response() now calls a new strategy method is_changed_on_failure() instead of hard-coding changed=False. The modified response header is authoritative in both directions when present; otherwise any per-item status: success in the recognized envelopes (results[]/switchIds[]/links[]) reports changed=True, and all-failed batches keep changed=False. Non-itemized embedded errors retain the conservative changed=False default. Covered by test_response_handler_nd_01450–01490 (mixed, all-failed, header-override both ways, DATA.error).
Related Issue(s)
Fixes #295
Relates to #397 (follow-up: consolidate the remaining orchestrators' bespoke 207 detectors)
Proposed Changes
ND reports per-item outcomes for batch operations in
DATA.results[]/DATA.switchIds[]items carryingstatus: success|failed|failure|error. It sends these bodies on HTTP 207 (Multi-Status) and, for someendpoints, on a plain HTTP 200 — the L3Out batch POST returns 200 with per-item failures inside.
NdV1Strategypreviously classified any success-code response as success, so a per-item failure (e.g. oneswitch fails while its peer succeeds, or a subinterface POST rejected because the parent is not routed) was
silently reported as
success/changed— masking failures and breaking idempotency.Central fix (
NdV1Strategy):is_success()now scans the body of every success-code response (200, 201, 202, 204, 207 — not only207) and returns
Falsewhen any per-itemstatusinresults[]/switchIds[]isfailed/failure/error(case-insensitive, whitespace-tolerant).extract_error_message()aggregates the failing items aslabel: message, so the user sees which itemsfailed and why. Dict-error handling factored into a helper to keep branch count in bounds.
partial-failure, a None-status control, and message aggregation.
Subinterface retirement: the managed and unmanaged subinterface orchestrators each carried their own
_raise_on_multi_status_failuresguard (withTODO(4.2.1)markers pointing at #295). These are nowredundant —
_request()raises via the strategy — and are removed along with their markers. Redundantdirect-call unit tests are dropped (covered centrally); the end-to-end 207 tests are kept and re-pointed to
the centralized message.
Downstream test updates: five tests in
manage_l3out,manage_acl, andmanage_policy_groupasserted abespoke per-item message that the centralized path now preempts for hard-failure literals. They are updated
to the centralized message. Behavior (raise) is preserved, and the bespoke detectors remain in place —
they still cover the allowlist / soft-status cases (
warning,notexecuted,!= "success") and alternateenvelope shapes the baseline does not. Retiring those is tracked in #397.
Affected orchestrator owners (FYI — your bespoke 207 detectors compose with this change; consolidation tracked in #397)
manage_acl,manage_l3outmanage_policy_groupnetworks/network_attachment_manager,vrfs/vrf_attachment_managersubinterface_*(retired here),maintenance_mode,fabric_update_groupTest Notes
Full unit suite green inside the
nd-devcontainer:ndpytest tests/unit/— 3152 passed.nd_v1_strategy.py: pylint 10.00/10, mypy clean, black/isort clean.Subinterface orchestrators: no new pylint/mypy findings vs
develop(verified against the pristinebaseline; pylint score improved after removing the duplicated guards).
Verified the central fix composes with the remaining bespoke detectors:
manage_policy_grouptest00830(bare-list body) still routes through its own detector, while00820(dict/switchIdsbody) is nowcaught centrally.
Follow-up commit "Clarify per-item failure detection docs (RestSend treats HTTP 207 Multi-Status as success even when results[].status is 'failed' or 'error' #295)" addresses the Copilot review: docstrings
and comments had described the detection as 207-only and omitted the
failureliteral, i.e. narrower thanthe code actually implements. Comments/docstrings only — no behavior change; suite re-run green.
Follow-up commit "Harden per-item failure extraction from review (RestSend treats HTTP 207 Multi-Status as success even when results[].status is 'failed' or 'error' #295)" addresses @akinross' review.
Adds two constant-driven helpers (
_get_typed_value,_first_non_empty) and fixes three defects, two ofwhich are pre-existing on
develop— this PR only moved them into_extract_dict_error_message, andthey sit on the path that reports an error to the user:
_format_multistatus_failuregated its label onis not None, so an item carryingname: ""renderedas
": <message>"."messages" in data_dict and len(data_dict.get("messages", []))raisedTypeErrorwhen ND sent the keywith an explicit
nullvalue (same forerrors) — despite the method's## Raises: Nonecontract.is_success()classifiesDATA.erroras a failure, but the extractor had no matching branch, so theerror ND actually sent was replaced by the generic
Request failed with status <code>fallback.Per-item message keys are now the
_MULTISTATUS_ITEM_MESSAGE_KEYSconstant (message,warningMessage,status) — ND is not consistent across endpoints. Note thewarningMessageitems onfabric_update_groupride an
attachUpdateGroupsenvelope that is not in_MULTISTATUS_ITEM_KEYS, so they still do notreach the central path; the message-key seam is in place, but the envelope-key half remains Consolidate per-item 207 Multi-Status detection: retire remaining bespoke orchestrator detectors onto NdV1Strategy #397's job.
Six new tests (
01300–01350). Full unit suite green — 3158 passed;nd_v1_strategy.pypylint10.00/10, mypy clean, black/isort clean.
Follow-up commit "Scan links[] Multi-Status envelope; label by linkId (PR Detect Multi-status per-item failures in NdV1Strategy (#295) #398 review)" addresses @shrsr's
request for
nd_manage_links. Addslinksto_MULTISTATUS_ITEM_KEYSandlinkIdto_MULTISTATUS_ITEM_LABEL_KEYS(ahead of the genericid) — bulk link create (POST /links) and bulk linkdelete (
POST /linkActions/remove) return HTTP 207 with{"links": [{"linkId", "message", "status"}]},status
success|failure(verified against the ND 4.2.1 OpenAPI). TheGET /linkslist body rides the sameenvelope, but its link objects carry no top-level
statuskey, so the literal-gated scan cannotfalse-positive on queries. Four new tests (
01360–01390), including the OpenAPI's ownlinkId: ""failure example. Full unit suite green — 3182 passed; pylint 10.00/10, mypy clean, black/isort clean.
Cisco Nexus Dashboard Version
4.2.1
Related ND API Resource Category
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01NAFsW8ALcvgZBSbgKjRPxn