diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0bf53f4..4186b68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
### Version 0.5.0
- Dropped Python 2.7, 3.6, and 3.7 support, minimum supported version is 3.8
- Migrate to PEP 517 compliant build with a `pyproject.toml` file
+- Added type annotation
+- Added `WSManFaultError` which contains WSManFault specific information when receiving a 500 WSMan fault response
+ - This contains pre-parsed values like the code, subcode, wsman fault code, wmi error code, and raw response
+ - It can be used by the caller to implement fallback behaviour based on specific error codes
### Version 0.4.3
- Fix invalid regex escape sequences.
diff --git a/winrm/exceptions.py b/winrm/exceptions.py
index 6282b20..db4f8a0 100644
--- a/winrm/exceptions.py
+++ b/winrm/exceptions.py
@@ -7,6 +7,64 @@ class WinRMError(Exception):
code = 500
+class WSManFaultError(WinRMError):
+ """WSMan Fault Error.
+
+ Exception that is raised when receiving a WSMan fault message. It
+ contains the raw response as well as the fault details parsed from the
+ response.
+
+ The wsman_fault_code is returned by the Microsoft WSMan server rather than
+ the WSMan protocol error code strings. The wmierror_code can contain more
+ fatal service error codes returned as a MSFT_WmiError object, for example
+ quota violations.
+
+ @param int code: The HTTP status code of the response.
+ @param str message: The error message.
+ @param str response: The raw WSMan response text.
+ @param str reason: The WSMan fault reason.
+ @param string fault_code: The WSMan fault code.
+ @param string fault_subcode: The WSMan fault subcode.
+ @param int wsman_fault_code: The MS WSManFault specific code.
+ @param int wmierror_code: The MS WMI error code.
+ """
+
+ def __init__(
+ self,
+ code: int,
+ message: str,
+ response: str,
+ reason: str,
+ fault_code: str | None = None,
+ fault_subcode: str | None = None,
+ wsman_fault_code: int | None = None,
+ wmierror_code: int | None = None,
+ ) -> None:
+ self.code = code
+ self.response = response
+ self.fault_code = fault_code
+ self.fault_subcode = fault_subcode
+ self.reason = reason
+ self.wsman_fault_code = wsman_fault_code
+ self.wmierror_code = wmierror_code
+
+ # Using the dict repr is for backwards compatibility.
+ fault_data = {
+ "transport_message": message,
+ "http_status_code": code,
+ }
+ if wsman_fault_code is not None:
+ fault_data["wsmanfault_code"] = wsman_fault_code
+
+ if fault_code is not None:
+ fault_data["fault_code"] = fault_code
+
+ if fault_subcode is not None:
+ fault_data["fault_subcode"] = fault_subcode
+
+ super().__init__("{0} (extended fault data: {1})".format(reason, fault_data))
+
+
class WinRMTransportError(Exception):
"""WinRM errors specific to transport-level problems (unexpected HTTP error codes, etc)"""
diff --git a/winrm/protocol.py b/winrm/protocol.py
index d096e29..e955d47 100644
--- a/winrm/protocol.py
+++ b/winrm/protocol.py
@@ -10,13 +10,19 @@
import xmltodict
-from winrm.exceptions import WinRMError, WinRMOperationTimeoutError, WinRMTransportError
+from winrm.exceptions import (
+ WinRMError,
+ WinRMOperationTimeoutError,
+ WinRMTransportError,
+ WSManFaultError,
+)
from winrm.transport import Transport
xmlns = {
"soapenv": "http://www.w3.org/2003/05/soap-envelope",
"soapaddr": "http://schemas.xmlsoap.org/ws/2004/08/addressing",
"wsmanfault": "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault",
+ "wmierror": "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/MSFT_WmiError",
}
@@ -247,33 +253,49 @@ def send_message(self, message: str) -> bytes:
raise ex
fault = root.find("soapenv:Body/soapenv:Fault", xmlns)
- if fault is not None:
- fault_data = dict(transport_message=ex.message, http_status_code=ex.code)
- wsmanfault_code = fault.find("soapenv:Detail/wsmanfault:WSManFault[@Code]", xmlns)
- if wsmanfault_code is not None:
- fault_data["wsmanfault_code"] = wsmanfault_code.get("Code")
- # convert receive timeout code to WinRMOperationTimeoutError
- if fault_data["wsmanfault_code"] == "2150858793":
- # TODO: this fault code is specific to the Receive operation; convert all op timeouts?
- raise WinRMOperationTimeoutError()
-
- fault_code = fault.find("soapenv:Code/soapenv:Value", xmlns)
- if fault_code is not None:
- fault_data["fault_code"] = fault_code.text
-
- fault_subcode = fault.find("soapenv:Code/soapenv:Subcode/soapenv:Value", xmlns)
- if fault_subcode is not None:
- fault_data["fault_subcode"] = fault_subcode.text
-
- error_message_node = fault.find("soapenv:Reason/soapenv:Text", xmlns)
- if error_message_node is not None:
- error_message = error_message_node.text
- else:
- error_message = "(no error message in fault)"
-
- raise WinRMError("{0} (extended fault data: {1})".format(error_message, fault_data))
-
- raise
+ if fault is None:
+ raise
+
+ wsmanfault_code_raw = fault.find("soapenv:Detail/wsmanfault:WSManFault[@Code]", xmlns)
+ wsmanfault_code: int | None = None
+ if wsmanfault_code_raw is not None:
+ wsmanfault_code = int(wsmanfault_code_raw.attrib["Code"])
+
+ # convert receive timeout code to WinRMOperationTimeoutError
+ if wsmanfault_code == 2150858793:
+ # TODO: this fault code is specific to the Receive operation; convert all op timeouts?
+ raise WinRMOperationTimeoutError()
+
+ fault_code_raw = fault.find("soapenv:Code/soapenv:Value", xmlns)
+ fault_code: str | None = None
+ if fault_code_raw is not None and fault_code_raw.text:
+ fault_code = fault_code_raw.text
+
+ fault_subcode_raw = fault.find("soapenv:Code/soapenv:Subcode/soapenv:Value", xmlns)
+ fault_subcode: str | None = None
+ if fault_subcode_raw is not None and fault_subcode_raw.text:
+ fault_subcode = fault_subcode_raw.text
+
+ error_message_node = fault.find("soapenv:Reason/soapenv:Text", xmlns)
+ reason: str | None = None
+ if error_message_node is not None:
+ reason = error_message_node.text
+
+ wmi_error_code_raw = fault.find("soapenv:Detail/wmierror:MSFT_WmiError/wmierror:error_Code", xmlns)
+ wmi_error_code: int | None = None
+ if wmi_error_code_raw is not None and wmi_error_code_raw.text:
+ wmi_error_code = int(wmi_error_code_raw.text)
+
+ raise WSManFaultError(
+ code=ex.code,
+ message=ex.message,
+ response=ex.response_text,
+ reason=reason or "(no error message in fault)",
+ fault_code=fault_code,
+ fault_subcode=fault_subcode,
+ wsman_fault_code=wsmanfault_code,
+ wmierror_code=wmi_error_code,
+ )
def close_shell(self, shell_id: str, close_session: bool = True) -> None:
"""
diff --git a/winrm/tests/test_exceptions.py b/winrm/tests/test_exceptions.py
new file mode 100644
index 0000000..d251672
--- /dev/null
+++ b/winrm/tests/test_exceptions.py
@@ -0,0 +1,256 @@
+import pytest
+
+from winrm.exceptions import (
+ WinRMOperationTimeoutError,
+ WinRMTransportError,
+ WSManFaultError,
+)
+
+
+def raise_exc(exc: Exception) -> None:
+ raise exc
+
+
+def test_wsman_fault_must_understand(protocol_fake):
+ xml_text = r"""
+
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/fault
+ uuid:4DB571F9-F8DE-48FD-872C-2AF08D996249
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous
+ uuid:eaa98952-3188-458f-b265-b03ace115f20
+
+
+
+
+
+ s:MustUnderstand
+
+
+ Test reason.
+
+
+
+"""
+
+ protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text))
+
+ with pytest.raises(WSManFaultError, match="Test reason\\.") as exc:
+ protocol_fake.open_shell()
+
+ assert isinstance(exc.value, WSManFaultError)
+ assert exc.value.code == 500
+ assert exc.value.response == xml_text
+ assert exc.value.fault_code == "s:MustUnderstand"
+ assert exc.value.fault_subcode is None
+ assert exc.value.wsman_fault_code is None
+ assert exc.value.wmierror_code is None
+
+
+def test_wsman_fault_no_reason(protocol_fake):
+ xml_text = r"""
+
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/fault
+ uuid:4DB571F9-F8DE-48FD-872C-2AF08D996249
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous
+ uuid:eaa98952-3188-458f-b265-b03ace115f20
+
+
+
+
+
+ s:Unknown
+
+
+
+"""
+
+ protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 501, xml_text))
+
+ with pytest.raises(WSManFaultError, match="no error message in fault") as exc:
+ protocol_fake.open_shell()
+
+ assert isinstance(exc.value, WSManFaultError)
+ assert exc.value.code == 501
+ assert exc.value.response == xml_text
+ assert exc.value.fault_code == "s:Unknown"
+ assert exc.value.fault_subcode is None
+ assert exc.value.wsman_fault_code is None
+ assert exc.value.wmierror_code is None
+
+
+def test_wsman_fault_known_fault(protocol_fake):
+ xml_text = r"""
+
+ http://schemas.dmtf.org/wbem/wsman/1/wsman/fault
+ uuid:D7C4A9B1-9A18-4048-B346-248D62A6078D
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous
+ uuid:7340FE92-C302-42E5-A337-1918908654F8
+
+
+
+
+ s:Receiver
+
+ w:TimedOut
+
+
+
+ The WS-Management service cannot complete the operation within the time specified in OperationTimeout.
+
+
+
+ The WS-Management service cannot complete the operation within the time specified in OperationTimeout.
+
+
+
+
+"""
+
+ protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text))
+
+ with pytest.raises(WinRMOperationTimeoutError):
+ protocol_fake.open_shell()
+
+
+def test_wsman_fault_with_wsmanfault(protocol_fake):
+ xml_text = r"""
+
+ http://schemas.dmtf.org/wbem/wsman/1/wsman/fault
+ uuid:EE71C444-1658-4B3F-916D-54CE43B68BC9
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous
+ uuid.761ca906-0bf0-41bb-a9d9-4cbbca986aeb
+
+
+
+
+ s:Sender
+
+ w:SchemaValidationError
+
+
+
+ Reason text.
+
+
+
+ Detail message.
+
+
+
+
+"""
+
+ protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text))
+
+ with pytest.raises(WSManFaultError, match="Reason text\\.") as exc:
+ protocol_fake.open_shell()
+
+ assert isinstance(exc.value, WSManFaultError)
+ assert exc.value.code == 500
+ assert exc.value.response == xml_text
+ assert exc.value.fault_code == "s:Sender"
+ assert exc.value.fault_subcode == "w:SchemaValidationError"
+ assert exc.value.wsman_fault_code == 0x80338041
+ assert exc.value.wmierror_code is None
+
+
+def test_wsman_fault_wmi_error_detail(protocol_fake):
+ xml_text = r"""
+
+ http://schemas.dmtf.org/wbem/wsman/1/wsman/fault
+ uuid:A832545B-9F5C-46AA-BB6A-5E4270D5E530
+ http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous
+
+
+
+
+ s:Receiver
+
+ w:InternalError
+
+
+
+ Reason text.
+
+
+
+ 27
+
+
+ 0
+ 0
+ WMI Message.
+ HRESULT 0x803381a6
+
+
+
+ 0
+ 0
+
+ 30
+ 2150859174
+ HRESULT
+ Windows Error message.
+
+
+
+
+"""
+
+ protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text))
+
+ with pytest.raises(WSManFaultError, match="Reason text\\.") as exc:
+ protocol_fake.open_shell()
+
+ assert isinstance(exc.value, WSManFaultError)
+ assert exc.value.code == 500
+ assert exc.value.response == xml_text
+ assert exc.value.fault_code == "s:Receiver"
+ assert exc.value.fault_subcode == "w:InternalError"
+ assert exc.value.wsman_fault_code is None
+ assert exc.value.wmierror_code == 0x803381A6