Skip to content

Commit 2a831a3

Browse files
author
Samson Gebre
committed
feat: enhance error handling in batch operations and improve related tests
1 parent 181f974 commit 2a831a3

5 files changed

Lines changed: 106 additions & 16 deletions

File tree

src/PowerPlatform/Dataverse/data/_batch.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from dataclasses import dataclass, field
1212
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
1313

14-
from ..core.errors import MetadataError, ValidationError
14+
from ..core.errors import HttpError, MetadataError, ValidationError
1515
from ..core._error_codes import METADATA_TABLE_NOT_FOUND, METADATA_COLUMN_NOT_FOUND
1616
from ..models.batch import BatchItemResponse, BatchResult
1717
from ..models.relationship import (
@@ -545,7 +545,12 @@ def _parse_batch_response(self, response: Any) -> BatchResult:
545545
content_type = response.headers.get("Content-Type", "")
546546
boundary = _extract_boundary(content_type)
547547
if not boundary:
548-
return BatchResult()
548+
# Non-multipart response: the batch request itself was rejected by Dataverse
549+
# (common for top-level 4xx, e.g. malformed body, missing OData headers).
550+
# Returning an empty BatchResult() here would silently hide the error and
551+
# make has_errors=False, which is actively misleading. Raise instead.
552+
_raise_top_level_batch_error(response)
553+
return BatchResult() # unreachable; satisfies type checkers
549554
parts = _split_multipart(response.text or "", boundary)
550555
responses: List[BatchItemResponse] = []
551556
for part_headers, part_body in parts:
@@ -569,6 +574,31 @@ def _parse_batch_response(self, response: Any) -> BatchResult:
569574
# ---------------------------------------------------------------------------
570575

571576

577+
def _raise_top_level_batch_error(response: Any) -> None:
578+
"""Parse a non-multipart batch response and raise HttpError with the service message.
579+
580+
Dataverse returns ``application/json`` with an ``{"error": {...}}`` payload when
581+
it rejects the batch request at the HTTP level (e.g. malformed multipart body,
582+
missing OData headers). This helper surfaces that detail instead of silently
583+
returning an empty ``BatchResult``.
584+
"""
585+
status_code: int = getattr(response, "status_code", 0)
586+
service_error_code: Optional[str] = None
587+
try:
588+
payload = response.json()
589+
error = payload.get("error", {})
590+
service_error_code = error.get("code") or None
591+
message: str = error.get("message") or response.text or "Unexpected non-multipart response from $batch"
592+
except Exception:
593+
message = (getattr(response, "text", None) or "") or "Unexpected non-multipart response from $batch"
594+
raise HttpError(
595+
message=f"Batch request rejected by Dataverse: {message}",
596+
status_code=status_code,
597+
subcode="4xx" if 400 <= status_code < 500 else ("5xx" if status_code >= 500 else None),
598+
service_error_code=service_error_code,
599+
)
600+
601+
572602
def _extract_boundary(content_type: str) -> Optional[str]:
573603
m = re.search(r'boundary="?([^";,\s]+)"?', content_type, re.IGNORECASE)
574604
return m.group(1) if m else None

src/PowerPlatform/Dataverse/operations/batch.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,9 @@ def upsert(
251251
"""
252252
Add an upsert operation to the batch.
253253
254-
Mirrors :meth:`~PowerPlatform.Dataverse.operations.records.RecordOperations.upsert`
255-
exactly: a single item becomes a PATCH request using the alternate key; multiple
256-
items become one ``UpsertMultiple`` POST.
254+
Mirrors :meth:`~PowerPlatform.Dataverse.operations.records.RecordOperations.upsert`:
255+
a single item becomes a PATCH request using the alternate key; multiple items
256+
become one ``UpsertMultiple`` POST.
257257
258258
Each item must be a :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`
259259
or a plain ``dict`` with ``"alternate_key"`` and ``"record"`` keys (both dicts).
@@ -262,6 +262,10 @@ def upsert(
262262
:param items: Non-empty list of :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`
263263
instances or equivalent dicts.
264264
265+
:raises TypeError: If ``items`` is not a non-empty list, or if any element is
266+
neither a :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem` nor a
267+
dict with ``"alternate_key"`` and ``"record"`` keys.
268+
265269
Example::
266270
267271
from PowerPlatform.Dataverse.models.upsert import UpsertItem
@@ -278,18 +282,15 @@ def upsert(
278282
])
279283
"""
280284
if not isinstance(items, list) or not items:
281-
raise ValidationError("items must be a non-empty list", subcode="VALIDATION_UPSERT_EMPTY")
285+
raise TypeError("items must be a non-empty list of UpsertItem or dicts")
282286
normalized: List[UpsertItem] = []
283287
for i in items:
284288
if isinstance(i, UpsertItem):
285289
normalized.append(i)
286290
elif isinstance(i, dict) and isinstance(i.get("alternate_key"), dict) and isinstance(i.get("record"), dict):
287291
normalized.append(UpsertItem(alternate_key=i["alternate_key"], record=i["record"]))
288292
else:
289-
raise ValidationError(
290-
"Each upsert item must be a UpsertItem or a dict with 'alternate_key' and 'record' keys.",
291-
subcode="VALIDATION_UPSERT_ITEM",
292-
)
293+
raise TypeError("Each item must be an UpsertItem or a dict with 'alternate_key' and 'record' keys")
293294
self._batch._items.append(_RecordUpsert(table=table, items=normalized))
294295

295296

src/PowerPlatform/Dataverse/operations/records.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ def upsert(self, table: str, items: List[Union[UpsertItem, Dict[str, Any]]]) ->
521521
elif isinstance(i, dict) and isinstance(i.get("alternate_key"), dict) and isinstance(i.get("record"), dict):
522522
normalized.append(UpsertItem(alternate_key=i["alternate_key"], record=i["record"]))
523523
else:
524-
raise TypeError("Each item must be a UpsertItem or a dict with 'alternate_key' and 'record' keys")
524+
raise TypeError("Each item must be an UpsertItem or a dict with 'alternate_key' and 'record' keys")
525525
with self._client._scoped_odata() as od:
526526
entity_set = od._entity_set_from_schema_name(table)
527527
if len(normalized) == 1:

tests/unit/data/test_batch_serialization.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
_TableList,
2020
_QuerySql,
2121
_extract_boundary,
22+
_raise_top_level_batch_error,
2223
_split_multipart,
2324
_parse_mime_part,
2425
_parse_http_response_part,
2526
_CRLF,
2627
)
28+
from PowerPlatform.Dataverse.core.errors import HttpError
2729
from PowerPlatform.Dataverse.models.upsert import UpsertItem
2830
from PowerPlatform.Dataverse.data._raw_request import _RawRequest
2931
from PowerPlatform.Dataverse.models.batch import BatchItemResponse, BatchResult
@@ -430,18 +432,16 @@ def test_upsert_plain_dict_normalised_to_upsert_item(self):
430432

431433
def test_upsert_empty_list_raises(self):
432434
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
433-
from PowerPlatform.Dataverse.core.errors import ValidationError
434435

435436
rec_ops, _ = self._make_batch()
436-
with self.assertRaises(ValidationError):
437+
with self.assertRaises(TypeError):
437438
rec_ops.upsert("account", [])
438439

439440
def test_upsert_invalid_item_raises(self):
440441
from PowerPlatform.Dataverse.operations.batch import BatchRecordOperations
441-
from PowerPlatform.Dataverse.core.errors import ValidationError
442442

443443
rec_ops, _ = self._make_batch()
444-
with self.assertRaises(ValidationError):
444+
with self.assertRaises(TypeError):
445445
rec_ops.upsert("account", ["not_a_valid_item"])
446446

447447
def test_upsert_multiple_items_all_normalised(self):
@@ -461,5 +461,65 @@ def test_upsert_multiple_items_all_normalised(self):
461461
self.assertEqual(intent.items[1].alternate_key, {"accountnumber": "B"})
462462

463463

464+
class TestRaiseTopLevelBatchError(unittest.TestCase):
465+
"""_raise_top_level_batch_error surfaces Dataverse error details as HttpError."""
466+
467+
def _make_response(self, status_code, json_body=None, text=None):
468+
resp = MagicMock()
469+
resp.status_code = status_code
470+
resp.text = text or ""
471+
if json_body is not None:
472+
resp.json.return_value = json_body
473+
else:
474+
resp.json.side_effect = ValueError("no JSON")
475+
return resp
476+
477+
def test_raises_http_error(self):
478+
"""Always raises HttpError, never returns."""
479+
resp = self._make_response(400, json_body={"error": {"code": "0x0", "message": "Bad batch"}})
480+
with self.assertRaises(HttpError):
481+
_raise_top_level_batch_error(resp)
482+
483+
def test_status_code_preserved(self):
484+
"""HttpError.status_code matches the response status code."""
485+
resp = self._make_response(400, json_body={"error": {"code": "0x0", "message": "Bad batch"}})
486+
with self.assertRaises(HttpError) as ctx:
487+
_raise_top_level_batch_error(resp)
488+
self.assertEqual(ctx.exception.status_code, 400)
489+
490+
def test_service_message_in_exception(self):
491+
"""The Dataverse error message is included in the raised exception."""
492+
resp = self._make_response(400, json_body={"error": {"code": "BadRequest", "message": "Malformed OData batch"}})
493+
with self.assertRaises(HttpError) as ctx:
494+
_raise_top_level_batch_error(resp)
495+
self.assertIn("Malformed OData batch", str(ctx.exception))
496+
497+
def test_service_error_code_preserved(self):
498+
"""The Dataverse error code is forwarded into HttpError.details."""
499+
resp = self._make_response(400, json_body={"error": {"code": "0x80040216", "message": "..."}})
500+
with self.assertRaises(HttpError) as ctx:
501+
_raise_top_level_batch_error(resp)
502+
self.assertEqual(ctx.exception.details.get("service_error_code"), "0x80040216")
503+
504+
def test_falls_back_to_response_text_when_no_json(self):
505+
"""Falls back to response.text when the body is not valid JSON."""
506+
resp = self._make_response(400, text="plain text error body")
507+
with self.assertRaises(HttpError) as ctx:
508+
_raise_top_level_batch_error(resp)
509+
self.assertIn("plain text error body", str(ctx.exception))
510+
511+
def test_parse_batch_response_raises_on_missing_boundary(self):
512+
"""_BatchClient._parse_batch_response raises HttpError for non-multipart responses."""
513+
od = _make_od()
514+
client = _BatchClient(od)
515+
resp = MagicMock()
516+
resp.headers = {"Content-Type": "application/json"}
517+
resp.status_code = 400
518+
resp.text = ""
519+
resp.json.return_value = {"error": {"code": "0x0", "message": "Invalid batch"}}
520+
with self.assertRaises(HttpError):
521+
client._parse_batch_response(resp)
522+
523+
464524
if __name__ == "__main__":
465525
unittest.main()

tests/unit/data/test_sql_parse.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
import unittest
54
from unittest.mock import patch
65
from urllib.parse import parse_qs, urlparse
76

0 commit comments

Comments
 (0)