Errors — python-ironicclient — branch stable/2026.2
Branch
stable/2026.2@af2ceeb9c4f2— repository https://opendev.org/openstack/python-ironicclient — analysed on 2026-11-01
[observed] means reproduced against a local stub, in _rewrite/scenarios_stable-2026.2.json under the id given in brackets. The stub is not Ironic. It shows how the client reacts to a response, not that Ironic sends this response.
1. Exit codes
| Code | Situation | Source |
|---|---|---|
| 0 | success, also after Please specify what to set. | cliff:cliff/command.py:196 |
| 1 | any exception raised by a command, shown as its text on stderr, without a traceback unless --debug | cliff:cliff/app.py:462, cliff:cliff/app.py:284 |
| 2 | argparse error, or unknown command | cliff:cliff/app.py:428 |
No take_action in the plugin returns a value from a plain Command, so the exit code trap of python-openstackclient does not occur here. This was checked by walking the AST of ironicclient/osc/v1/*.py: the only returns outside Lister and ShowOne carry no value.
Exception text is all the user sees. A Python error therefore appears as bare text, such as id or 'links', with exit 1.
2. From an HTTP response to a message
ironicclient/common/http.py:427 handles each response:
| Status | Client action | Result for the user | Source |
|---|---|---|---|
| 2xx | decode | ironicclient/common/http.py:505 | |
| 300 | error | Multiple Choices class | ironicclient/common/http.py:484 |
| 301, 302, 305 | reissue at Location; keystoneauth may follow them first [inferred] | ironicclient/common/http.py:479 | |
| 401 | keystoneauth gets a new token and retries once [observed err-401, 2 requests] | <message> (HTTP 401) | keystoneauth1:keystoneauth1/session.py:1130 |
| 406 | negotiate, see 03 § 3 | retried, or UnsupportedVersion | ironicclient/common/http.py:464 |
| 409, 503 | retry, 5 times, 2 s apart | after 6 attempts, the message, plus a log line Error contacting Ironic server: … Attempt 6 of 6 [observed err-409-exhausts-retries] | ironicclient/common/http.py:324 |
| other ≥ 400 | error class by status | <message> (HTTP <status>) | ironicclient/common/http.py:469 |
| connection failure | retry as above [observed legacy-connection-refused] | Unable to establish connection to … | ironicclient/common/http.py:326 |
Reading the message (ironicclient/common/http.py:95):
- the body is JSON, and has
error_message; - that value is itself parsed as JSON; if it has
faultstringordescription, that text is the message; - if it does not parse, the raw string is the message [observed
err-400-plain-error-message]; debuginfogoes intodetails.
If X-OpenStack-Request-Id is on the response, the message ends with (Request-ID: …) [observed err-404-request-id]. If not, x-compute-request-id is tried (ironicclient/common/apiclient/exceptions.py:437).
**Without error_message**, the message is only the class default [observed err-400-other-body, err-500-html]: Bad Request (HTTP 400), Internal Server Error (HTTP 500). exc.from_response replaces the body read by the shared code with a dict built from those fields (ironicclient/exc.py:81), so a message or faultstring elsewhere in the body is never seen.
Frame sent by Ironic, per its code (ironic:ironic/api/middleware/parsable_error.py:71), as scripted on the stub:
{"error_message": "{\"faultcode\": \"Client\", \"faultstring\": \"Node x could not be found.\", \"debuginfo\": null}"}
gives Node x could not be found. (HTTP 404) [observed err-404-ironic-body].
3. Exception classes
43 classes in ironicclient/common/apiclient/exceptions.py, listed in _inventory/inventory_stable-2026.2.md, and 4 in ironicclient/exc.py. None of them has an HTTP role beyond the mapping below.
| Family | Classes | Raised when | Source |
|---|---|---|---|
| Client-side | CommandError | bad value on the command line | ironicclient/common/apiclient/exceptions.py:50 |
| Client-side | InvalidAttribute | unknown creation attribute, detail with fields, bad JSON file, non-object list response | ironicclient/exc.py:40, ironicclient/common/base.py:251 |
| Client-side | ValidationError | empty identifier on a get (Value provided: …) | ironicclient/common/base.py:105 |
| Versions | UnsupportedVersion | negotiation failure, runbook gate | ironicclient/common/apiclient/exceptions.py:45, ironicclient/common/http.py:222 |
| Endpoint | EndpointNotFound, AmbiguousAuthSystem | no endpoint, or no credentials in get_client | ironicclient/common/http.py:395, ironicclient/client.py:115 |
| Waiting | StateTransitionFailed, StateTransitionTimeout | see 05 | ironicclient/exc.py:44, ironicclient/exc.py:48 |
| HTTP | one per status in _code_map: 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 405 MethodNotAllowed, 406 NotAcceptable, 409 Conflict, 413 RequestEntityTooLarge, 500 InternalServerError, 503 ServiceUnavailable, and others | built by from_response; other 4xx give HTTPClientError, other 5xx give HttpServerError | ironicclient/common/apiclient/exceptions.py:420, ironicclient/common/apiclient/exceptions.py:427 |
| Aliases | InvalidEndpoint, CommunicationError, HTTPBadRequest, HTTPNotFound, HTTPServiceUnavailable, HTTPInternalServerError, AmbigiousAuthSystem | never raised under these names; kept for old callers | ironicclient/exc.py:23, ironicclient/exc.py:37 |
NotFound is raised without a response by show --instance, port show --address and port group show --address: the text is Not Found (HTTP 404) although the service answered 200 [observed in _rewrite/wire-options_stable-2026.2.json] (ironicclient/v1/node.py:366).
4. Messages
Raised in the managers and the HTTP layer
| Message | Exception | Situation | Source |
|---|---|---|---|
Requested API version … is not supported by the server, client, or the requested operation is not supported… | UnsupportedVersion | explicit version got a 406, or a per-call version | ironicclient/common/http.py:222 |
No API version was specified or the requested operation was not supported by the client's negotiated API version… | UnsupportedVersion | a 406 after negotiation | ironicclient/common/http.py:231 |
Could not determine API version range from server; version headers missing from response. | RuntimeError | no version headers on GET /v1 [observed neg-406-no-headers] | ironicclient/common/http.py:200 |
The Bare Metal API endpoint cannot be detected and was not provided explicitly | EndpointNotFound | no endpoint | ironicclient/common/http.py:395 |
API response body must be a JSON object; got … | InvalidAttribute | list response is not an object [observed shape-list-body-is-array, shape-show-text-body] | ironicclient/common/base.py:251, ironicclient/common/base.py:304 |
The attribute(s) "…" are invalid; they are not needed to create … | InvalidAttribute | Python API, or baremetal create with an unknown key | ironicclient/common/base.py:492 |
Can't fetch a subset of fields with 'detail' set | InvalidAttribute | detail with fields | ironicclient/v1/node.py:121, ironicclient/v1/port.py:168, ironicclient/v1/chassis.py:234 |
'soft' option is invalid for the power-state 'on' | ValueError | Python API only; the command offers no --soft on power on | ironicclient/v1/node.py:596 |
'timeout' option for setting power state must have positive integer value (> 0) | ValueError | --power-timeout 0 or less | ironicclient/v1/node.py:606 |
Valid boot modes are 'uefi' or 'bios' | ValueError | Python API; the command has choices | ironicclient/v1/node.py:641 |
Config drive seems to refer to a file or directory but this file/directory does not exist: … | ValueError | [observed req-config-drive-base64] | ironicclient/v1/node.py:798 |
Config drive must be a dictionary or a base64 encoded string | ValueError | a file holding non-UTF-8 data | ironicclient/v1/node.py:806 |
Unknown HTTP method: … | InvalidAttribute | Python API; the commands have choices | ironicclient/v1/node.py:450, ironicclient/v1/driver.py:171 |
The attribute 'id' can't be specified in vif-info | InvalidAttribute | vif attach --vif-info id=… | ironicclient/v1/node.py:495 |
Runbook trait operations require API version 1.112 or later. | UnsupportedVersion | gate, [observed req-runbook-trait-gate] where trait add wraps it as Failed to add trait … | ironicclient/v1/runbook.py:176 |
Node … failed to reach state … It's in state …, and has error: … | StateTransitionFailed | [observed wait-manage-failed] | ironicclient/v1/node.py:1142 |
Node … failed to reach state … It's in unexpected stable state … | StateTransitionFailed | [observed wait-manage-other-stable] | ironicclient/v1/node.py:1149 |
Node(s) … failed to reach state … in … seconds | StateTransitionTimeout | [observed wait-manage-timeout] | ironicclient/v1/node.py:1207 |
Allocation … failed: … | StateTransitionFailed | [observed wait-allocation-error] | ironicclient/v1/allocation.py:132 |
Allocation … failed to become active in … seconds | StateTransitionTimeout | ironicclient/v1/allocation.py:115 | |
Error generating the config drive. Make sure the "genisoimage", "mkisofs", or "xorriso" tool is installed… | CommandError | no binary; not run | ironicclient/common/utils.py:297 |
Timeout must be a non-negative number | ValueError | negative --wait | ironicclient/common/utils.py:429 |
Raised in the commands (generated from the catalogue)
Found by walking the code from take_action, so this table is a floor. "Message built at run time" groups the Failed to delete … families.
| Message (template) | Exception | Raised at | Commands |
|---|---|---|---|
| %(param)s specified with value not contained in field_ids. Unknown value(s): %(unknown)s | ValueError | ironicclient/v1/resource_fields.py:208 | baremetal allocation list, baremetal chassis list, baremetal conductor list (+10) |
| '--wait' is not supported for provision state '%s' | CommandError | ironicclient/osc/v1/baremetal_node.py:182 | baremetal node adopt, baremetal node clean, baremetal node deploy (+8) |
| (message built at run time) | ClientException | ironicclient/osc/v1/baremetal_allocation.py:308, ironicclient/osc/v1/baremetal_chassis.py:120, ironicclient/osc/v1/baremetal_deploy_template.py:281, ironicclient/osc/v1/baremetal_inspection_rule.py:347 (+10) | baremetal allocation delete, baremetal chassis delete, baremetal deploy template delete (+13) |
| --description requires API version 1.112 or later | UnsupportedVersion | ironicclient/osc/v1/baremetal_runbook.py:123, ironicclient/osc/v1/baremetal_runbook.py:264, ironicclient/osc/v1/baremetal_runbook.py:369 | baremetal runbook create, baremetal runbook set, baremetal runbook unset |
| --instance-uuid cannot be used with more than one node | CommandError | ironicclient/osc/v1/baremetal_node.py:1673 | baremetal node set |
| --name cannot be used with more than one node | CommandError | ironicclient/osc/v1/baremetal_node.py:1670 | baremetal node set |
| --reset-interfaces can only be specified with --driver | CommandError | ironicclient/osc/v1/baremetal_node.py:1709 | baremetal node set |
| --resource-class is required except when --node is used | ClientException | ironicclient/osc/v1/baremetal_allocation.py:104 | baremetal allocation create |
| argument %(arg)s: %(err)s. | CommandError | ironicclient/common/utils.py:351 | baremetal runbook create, baremetal runbook set |
| Attributes must be a list of PATH=VALUE not "%s" | CommandError | ironicclient/common/utils.py:63 | baremetal allocation create, baremetal allocation set, baremetal allocation unset (+29) |
| Cannot get %(desc)s from standard input. Error: %(err)s | InvalidAttribute | ironicclient/common/utils.py:388 | baremetal deploy template create, baremetal deploy template set, baremetal inspection rule create (+7) |
| Cannot get JSON/YAML from file '%(file)s'. Error: %(err)s | InvalidAttribute | ironicclient/common/utils.py:411 | baremetal deploy template create, baremetal deploy template set, baremetal inspection rule create (+7) |
| Cannot process file "%(file)s" - it must have .json or .yaml extension. | ClientException | ironicclient/v1/create_resources.py:102 | baremetal create |
| Cannot read file "%(file)s" due to error: %(err)s | ClientException | ironicclient/v1/create_resources.py:106 | baremetal create |
| During resources creation, the following error(s) were encountered: %s | ClientException | ironicclient/v1/create_resources.py:81 | baremetal create |
| Expected non-negative --boot-index, got %s | CommandError | ironicclient/osc/v1/baremetal_volume_target.py:100, ironicclient/osc/v1/baremetal_volume_target.py:359 | baremetal volume target create, baremetal volume target set |
| Expected non-negative --limit, got %s | CommandError | ironicclient/osc/v1/baremetal_allocation.py:245, ironicclient/osc/v1/baremetal_chassis.py:186, ironicclient/osc/v1/baremetal_conductor.py:95, ironicclient/osc/v1/baremetal_deploy_template.py:348 (+7) | baremetal allocation list, baremetal chassis list, baremetal conductor list (+8) |
| Failed to delete all rules: %s | ClientException | ironicclient/osc/v1/baremetal_inspection_rule.py:334 | baremetal inspection rule delete |
| File "%(file)s" is invalid due to error: %(err)s | ClientException | ironicclient/v1/create_resources.py:111 | baremetal create |
| invalid version number %s | ValueError | ironicclient/common/http.py:74 | baremetal runbook create, baremetal runbook set, baremetal runbook unset |
| Unable to create the chassis: the API returned an empty response | ClientException | ironicclient/v1/create_resources.py:252 | baremetal create |
| Unable to create the node: the API returned an empty response | ClientException | ironicclient/v1/create_resources.py:180 | baremetal create |
| Unable to create the port group: the API returned an empty response | ClientException | ironicclient/v1/create_resources.py:228 | baremetal create |
| Unable to create the port: the API returned an empty response | ClientException | ironicclient/v1/create_resources.py:204 | baremetal create |
| Unknown PATCH operation: %s | CommandError | ironicclient/common/utils.py:126 | baremetal allocation set, baremetal allocation unset, baremetal chassis set (+17) |
| Value '%(string)s' is not a file and cannot be parsed as JSON: '%(err)s' | InvalidAttribute | ironicclient/common/utils.py:417 | baremetal deploy template create, baremetal deploy template set, baremetal inspection rule create (+7) |
| While validating the resources file(s), the following error(s) were encountered: %s | ClientException | ironicclient/v1/create_resources.py:70 | baremetal create |
| You cannot supply --runbook and --disable-ramdisk together | CommandError | ironicclient/osc/v1/baremetal_node.py:118 | baremetal node abort, baremetal node unhold |
Logged, not raised
| Log | Level | Situation | Source |
|---|---|---|---|
Error contacting Ironic server: … Attempt n of m | debug, then error on the last attempt | retries | ironicclient/common/http.py:352 |
Could not decode response body as JSON | error | JSON content type, bad body; the command may still exit 0 [observed shape-driver-passthru-empty] | ironicclient/common/http.py:512 |
Please specify what to set. / unset. | warning | no option given; exit 0 | ironicclient/osc/v1/baremetal_node.py:1748 |
The following arguments are ignored when using the session to construct a client | warning | Python API with token, ca_file and so on | ironicclient/common/http.py:562 |
5. Defects
Each was reproduced against the stub or read in the code, as marked. Only the client is at fault here, unless stated. "Real Ironic" says whether a conforming server would trigger it, from reading its code only.
| # | Defect | Reproduced | Real Ironic | Source |
|---|---|---|---|---|
| 1 | **driver passthru call fails on a JSON object answer.** A Driver resource reaches dict2columns, which reads .items; the lazy loader then fails on id. Stderr id, exit 1, after the service acted. The unit test mocks a dict. | [observed shape-driver-passthru-json] | yes, for a synchronous vendor method returning an object [inferred] | ironicclient/osc/v1/baremetal_driver.py:208, ironicclient/v1/driver.py:150, ironicclient/common/apiclient/base.py:116, ironicclient/tests/unit/osc/v1/test_baremetal_driver.py:298 |
| 2 | **node children list shows only the first child.** ShowOne with one column and one row per child. | [observed shape-node-children-two] | yes, the answer shape matches ironic:ironic/api/controllers/v1/node.py:2342 | ironicclient/osc/v1/baremetal_node.py:2640, cliff:cliff/show.py:59 |
| 3 | **A Retry-After header replaces 429 and 503 with a TypeError**, and skips the 503 retry. Only 413 accepts it. Also any other status with the header. | [observed err-503-retry-after, err-429-retry-after; 413 is fine in err-413-retry-after] | not from Ironic's API code; possible from a proxy [inferred] | ironicclient/common/apiclient/exceptions.py:449, ironicclient/common/apiclient/exceptions.py:115, ironicclient/common/apiclient/exceptions.py:304 |
| 4 | **inspection rule delete all cannot delete all.** nargs='+' gives a list, compared with 'all'. The branch is dead; it would also call delete() with no required argument. The client sends DELETE /v1/inspection_rules/all, and prints Deleted rule all if the service accepts it. | [observed req-rule-delete-all] | Ironic's route list has DELETE /v1/inspection_rules, which this client cannot send | ironicclient/osc/v1/baremetal_inspection_rule.py:329, ironicclient/osc/v1/baremetal_inspection_rule.py:331, ironicclient/v1/inspection_rule.py:50 |
| 5 | **inspection rule unset --action and --condition patch the wrong path**: /action/0, /condition/1, while the fields are actions and conditions. | [observed req-rule-unset-action]; rejection by Ironic is [inferred] | likely fails at ironic:ironic/api/controllers/v1/inspection_rule.py:288 | ironicclient/osc/v1/baremetal_inspection_rule.py:288, ironicclient/v1/inspection_rule.py:32 |
| 6 | **--config-drive <base64> is refused** although the help and the docstring offer it. Only bytes through the Python API work. | [observed req-config-drive-base64] | client-side, no request | ironicclient/osc/v1/baremetal_node.py:37, ironicclient/v1/node.py:745, ironicclient/v1/node.py:797 |
| 7 | Query values are not URL-encoded, so & and = in a value add parameters. Also true of names in paths. | [observed req-filter-not-encoded] | yes | ironicclient/v1/node.py:1493, ironicclient/common/utils.py:231 |
| 8 | **Three messages of baremetal create are not formatted**: a comma instead of %, so the user sees a Python tuple with %(node_uuid)s. | [observed create-file-port-mismatch] | client-side | ironicclient/v1/create_resources.py:278, ironicclient/v1/create_resources.py:289, ironicclient/v1/create_resources.py:321 |
| 9 | **set changes the type of a value.** --name 123 sends a number, --description true a boolean, while create --name 123 sends a string. Applies to every set. | [observed req-set-name-numeric, req-set-description-json, req-create-name-numeric] | a schema would refuse it [inferred] | ironicclient/common/utils.py:66, ironicclient/osc/v1/baremetal_node.py:1696 |
| 10 | **error_message of another type crashes the parser**: an object gives TypeError, text parsing as a number gives 'int' object has no attribute 'get', a JSON null body gives argument of type 'NoneType' is not iterable. Only ValueError is caught. | [observed err-400-dict-error-message, err-400-numeric-error-message, err-400-json-null] | no: Ironic sends a string; a faultstring-less text that parses as JSON is unlikely | ironicclient/common/http.py:102, ironicclient/common/http.py:106, ironicclient/common/http.py:110 |
| 11 | Missing keys crash commands, as text with no context: 'links' on node history get (others use pop(…, None)), 'http_methods' on passthru list, 'NoneType' object has no attribute 'pop' on bios setting show, not enough values to unpack on boot device show and console show, 'NoneType' object has no attribute 'uuid' on allocation create --wait, 'list' object has no attribute 'items' on node passthru call with an array. | [observed shape-*, wait-allocation-create-empty] | only {}-like or array answers; passthru answers are vendor-defined [inferred] | ironicclient/osc/v1/baremetal_node.py:2574, ironicclient/osc/v1/baremetal_node.py:1163, ironicclient/osc/v1/baremetal_driver.py:241, ironicclient/osc/v1/baremetal_node.py:2495, ironicclient/osc/v1/baremetal_node.py:291, ironicclient/osc/v1/baremetal_allocation.py:117, ironicclient/common/apiclient/base.py:83 |
| 12 | **200 {} on a show or create gives 'NoneType' object has no attribute '_info'** on 15 commands, because {} is dropped by if res. Listed by exit_200: 1 in the contract. | [observed shape-show-empty-object, _rewrite/wire-probe-200_stable-2026.2.json] | no [inferred] | ironicclient/common/base.py:357, ironicclient/common/base.py:118, ironicclient/osc/v1/baremetal_node.py:1797 |
| 13 | A 406 response is never read for its version headers. requests.Response is false on an error status, so if not resp always queries GET /v1, and the version_overridden path can never run. If GET /v1 fails, negotiation fails, although the 406 had the data. | [observed neg-406-v1-500, neg-406-downgrade] | works, at the cost of one more request | ironicclient/common/http.py:159, ironicclient/common/http.py:169, ironicclient/common/http.py:182, requests:requests/models.py:835 |
Traps that are not bugs:
POSTis retried on 409, 503 and connection failures, so a request that the server applied before the connection dropped could be sent twice [inferred].err-409-exhausts-retriesshows 6POST.--waitmay return at once if the node still shows the expected state, forserviceandrebuildwhere start and end state are bothactive[observedwait-service-already-activewith a scripted answer; whether Ironic can answer this was not checked] (ironicclient/v1/node.py:1134)._getreturnsNonefor{}and the wait loop treats that as "not yet", so a stuck endpoint with no--waittimeout polls forever (ironicclient/v1/node.py:1132,ironicclient/common/utils.py:438) [read].- Unit tests pass for all of these, since they mock the managers (
ironicclient/tests/unit/osc/v1/fakes.py:367).
Source: reverse-spec-python-ironicclient/02-errors_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.