From c035784b29d8d08705c41ea56ec22e1f93d90d04 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Mon, 7 Sep 2026 16:22:33 +0200 Subject: [PATCH 1/3] fix: lead APIError's string form with the error message str(APIError) started with a newline and printed message: None for every generated error model, ignoring the primary_message the generator emits, so any log line or error title built from the first line was empty. The first line now carries the message (primary_message, then message, then the class name), the status code and the error code; the nested error payload follows on a second line. --- .../kiota_abstractions/api_error.py | 24 +++++---- packages/abstractions/tests/test_api_error.py | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 packages/abstractions/tests/test_api_error.py diff --git a/packages/abstractions/kiota_abstractions/api_error.py b/packages/abstractions/kiota_abstractions/api_error.py index e3b630f4..61628668 100644 --- a/packages/abstractions/kiota_abstractions/api_error.py +++ b/packages/abstractions/kiota_abstractions/api_error.py @@ -11,16 +11,18 @@ class APIError(Exception): response_headers: Optional[dict[str, str]] = None def __str__(self) -> str: + # Generated error models carry the server's message on ``primary_message`` and the + # error payload on ``error``; the base class knows neither, so both are optional here. + # The first line leads with the message so log lines and error titles show it. error = getattr(self, "error", None) + message = getattr(self, "primary_message", None) or self.message or type(self).__name__ + details = [] + if self.response_status_code is not None: + details.append(f"status {self.response_status_code}") + code = getattr(error, "code", None) + if code: + details.append(f"code {code}") + first_line = f"{message} ({', '.join(details)})" if details else str(message) if error: - return f""" - APIError - Code: {self.response_status_code} - message: {self.message} - error: {error} - """ - return f""" - APIError - Code: {self.response_status_code} - message: {self.message} - """ + return f"{first_line}\nerror: {error}" + return first_line diff --git a/packages/abstractions/tests/test_api_error.py b/packages/abstractions/tests/test_api_error.py new file mode 100644 index 00000000..fc9ac36c --- /dev/null +++ b/packages/abstractions/tests/test_api_error.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import Optional + +from kiota_abstractions.api_error import APIError + + +@dataclass +class MainError: + code: Optional[str] = None + message: Optional[str] = None + + +@dataclass +class GeneratedError(APIError): + """The shape kiota generates for an API's error model: an ``error`` payload and a + ``primary_message`` property, and no ``message`` set by the deserializer.""" + + error: Optional[MainError] = None + + @property + def primary_message(self) -> str: + if self.error is not None: + return self.error.message or "" + return "" + + +def test_str_is_the_message(): + assert str(APIError(message="boom")) == "boom" + + +def test_str_appends_the_status_code(): + assert str(APIError(message="boom", response_status_code=429)) == "boom (status 429)" + + +def test_str_without_any_message_names_the_class(): + assert str(APIError(response_status_code=502)) == "APIError (status 502)" + + +def test_str_of_a_generated_error_leads_with_the_primary_message(): + message = "Application is over its MailboxConcurrency limit." + error = GeneratedError( + response_status_code=429, + error=MainError(code="ApplicationThrottled", message=message), + ) + + lines = str(error).splitlines() + + assert lines[0] == f"{message} (status 429, code ApplicationThrottled)" + assert lines[1].startswith("error: MainError(") + + +def test_str_never_starts_with_a_blank_line(): + for error in (APIError(), APIError(message="boom"), GeneratedError(error=MainError())): + assert str(error).splitlines()[0].strip() From 54848f9906b9c9e7c089859d7c6eef94abead09c Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 10 Sep 2026 17:16:15 +0200 Subject: [PATCH 2/3] fix: drop the OData error code from APIError's string form The base class has no notion of an error code; `error.code` is the OData error contract as Graph exposes it. The first line is now the message and the status only, the code stays visible on the error line. --- packages/abstractions/kiota_abstractions/api_error.py | 8 ++------ packages/abstractions/tests/test_api_error.py | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/abstractions/kiota_abstractions/api_error.py b/packages/abstractions/kiota_abstractions/api_error.py index 61628668..ccfdc096 100644 --- a/packages/abstractions/kiota_abstractions/api_error.py +++ b/packages/abstractions/kiota_abstractions/api_error.py @@ -16,13 +16,9 @@ def __str__(self) -> str: # The first line leads with the message so log lines and error titles show it. error = getattr(self, "error", None) message = getattr(self, "primary_message", None) or self.message or type(self).__name__ - details = [] + first_line = str(message) if self.response_status_code is not None: - details.append(f"status {self.response_status_code}") - code = getattr(error, "code", None) - if code: - details.append(f"code {code}") - first_line = f"{message} ({', '.join(details)})" if details else str(message) + first_line = f"{message} (status {self.response_status_code})" if error: return f"{first_line}\nerror: {error}" return first_line diff --git a/packages/abstractions/tests/test_api_error.py b/packages/abstractions/tests/test_api_error.py index fc9ac36c..f3bc66b8 100644 --- a/packages/abstractions/tests/test_api_error.py +++ b/packages/abstractions/tests/test_api_error.py @@ -45,8 +45,9 @@ def test_str_of_a_generated_error_leads_with_the_primary_message(): lines = str(error).splitlines() - assert lines[0] == f"{message} (status 429, code ApplicationThrottled)" + assert lines[0] == f"{message} (status 429)" assert lines[1].startswith("error: MainError(") + assert "code='ApplicationThrottled'" in lines[1] def test_str_never_starts_with_a_blank_line(): From 4276f2ba6ea79c01377c65a58f4cd6de48a8024c Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Thu, 10 Sep 2026 19:48:22 +0200 Subject: [PATCH 3/3] fix: render APIError through a primary_message property instead of getattr lookups primary_message is a real property on the base class, returning message, that generated error models override; __str__ reads it plus the status code and no longer looks up error or primary_message by name. --- .../kiota_abstractions/api_error.py | 17 ++++++++--------- packages/abstractions/tests/test_api_error.py | 17 +++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/abstractions/kiota_abstractions/api_error.py b/packages/abstractions/kiota_abstractions/api_error.py index ccfdc096..95177bb9 100644 --- a/packages/abstractions/kiota_abstractions/api_error.py +++ b/packages/abstractions/kiota_abstractions/api_error.py @@ -10,15 +10,14 @@ class APIError(Exception): response_status_code: Optional[int] = None response_headers: Optional[dict[str, str]] = None + @property + def primary_message(self) -> Optional[str]: + """The message shown for this error. Generated error models override it with the + message carried by the API's error payload.""" + return self.message + def __str__(self) -> str: - # Generated error models carry the server's message on ``primary_message`` and the - # error payload on ``error``; the base class knows neither, so both are optional here. - # The first line leads with the message so log lines and error titles show it. - error = getattr(self, "error", None) - message = getattr(self, "primary_message", None) or self.message or type(self).__name__ - first_line = str(message) + first_line = self.primary_message or type(self).__name__ if self.response_status_code is not None: - first_line = f"{message} (status {self.response_status_code})" - if error: - return f"{first_line}\nerror: {error}" + return f"{first_line} (status {self.response_status_code})" return first_line diff --git a/packages/abstractions/tests/test_api_error.py b/packages/abstractions/tests/test_api_error.py index f3bc66b8..be925b01 100644 --- a/packages/abstractions/tests/test_api_error.py +++ b/packages/abstractions/tests/test_api_error.py @@ -13,12 +13,12 @@ class MainError: @dataclass class GeneratedError(APIError): """The shape kiota generates for an API's error model: an ``error`` payload and a - ``primary_message`` property, and no ``message`` set by the deserializer.""" + ``primary_message`` override, and no ``message`` set by the deserializer.""" error: Optional[MainError] = None @property - def primary_message(self) -> str: + def primary_message(self) -> Optional[str]: if self.error is not None: return self.error.message or "" return "" @@ -36,18 +36,19 @@ def test_str_without_any_message_names_the_class(): assert str(APIError(response_status_code=502)) == "APIError (status 502)" -def test_str_of_a_generated_error_leads_with_the_primary_message(): +def test_primary_message_defaults_to_the_message(): + assert APIError(message="boom").primary_message == "boom" + assert APIError().primary_message is None + + +def test_str_of_a_generated_error_uses_its_primary_message(): message = "Application is over its MailboxConcurrency limit." error = GeneratedError( response_status_code=429, error=MainError(code="ApplicationThrottled", message=message), ) - lines = str(error).splitlines() - - assert lines[0] == f"{message} (status 429)" - assert lines[1].startswith("error: MainError(") - assert "code='ApplicationThrottled'" in lines[1] + assert str(error) == f"{message} (status 429)" def test_str_never_starts_with_a_blank_line():