Skip to content

Commit 44534d0

Browse files
committed
feat(services): best-effort batch dispatcher
Pro-Item-Fehler → BatchError-Liste. Hardware-Vorbedingungen (printer_offline, cover_open) sind batch-fatal und propagieren. Refs strausmann/hangar#78
1 parent d6cd621 commit 44534d0

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Best-effort Batch-Dispatcher: validiert + queued pro Item, sammelt Errors."""
2+
from __future__ import annotations
3+
4+
import logging
5+
6+
from app.printer_backends.exceptions import (
7+
PrinterCoverOpenError,
8+
PrinterOfflineError,
9+
SnmpQueryError,
10+
TapeEmptyError,
11+
TapeMismatchError,
12+
)
13+
from app.schemas.print_batch import BatchError
14+
from app.schemas.print_request import PrintRequest
15+
from app.services.lookup_service import LookupFailedError
16+
from app.services.template_loader import TemplateNotFoundError
17+
18+
_log = logging.getLogger(__name__)
19+
20+
# Per-item errors → collected into BatchError list (best-effort)
21+
_PER_ITEM_ERRORS: dict[type[Exception], str] = {
22+
TemplateNotFoundError: "template_not_found",
23+
LookupFailedError: "integration_lookup_failed",
24+
TapeMismatchError: "tape_mismatch",
25+
TapeEmptyError: "tape_empty",
26+
}
27+
28+
# Hardware preconditions → propagate (caller returns 409)
29+
_BATCH_FATAL_ERRORS: tuple[type[Exception], ...] = (
30+
PrinterCoverOpenError,
31+
PrinterOfflineError,
32+
SnmpQueryError,
33+
)
34+
35+
36+
async def dispatch_batch(
37+
service, # PrintService (duck-typed)
38+
items: list[PrintRequest],
39+
) -> tuple[list[str], list[BatchError]]:
40+
"""Queue each item individually. Collect per-item errors.
41+
Hardware errors propagate."""
42+
job_ids: list[str] = []
43+
errors: list[BatchError] = []
44+
45+
for index, item in enumerate(items):
46+
try:
47+
job_id = await service.submit_print_job(item)
48+
job_ids.append(str(job_id))
49+
except _BATCH_FATAL_ERRORS:
50+
raise
51+
except tuple(_PER_ITEM_ERRORS) as exc:
52+
code = _PER_ITEM_ERRORS[type(exc)]
53+
detail = None
54+
if isinstance(exc, TapeMismatchError):
55+
detail = {"expected_mm": exc.expected_mm,
56+
"loaded_mm": exc.loaded_mm}
57+
errors.append(BatchError(
58+
index=index, error_code=code,
59+
error_message=str(exc), error_detail=detail,
60+
))
61+
except Exception as exc: # unknown sync failure
62+
_log.exception("unexpected error in batch item %d", index)
63+
errors.append(BatchError(
64+
index=index, error_code="internal_error",
65+
error_message=str(exc),
66+
))
67+
68+
return job_ids, errors
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Unit-Tests für den Batch-Dispatcher (best-effort, pro-Item-Validation)."""
2+
from __future__ import annotations
3+
4+
from uuid import uuid4
5+
6+
import pytest
7+
8+
from app.schemas.print_batch import BatchError
9+
from app.schemas.print_request import PrintRequest, RawLabelData
10+
from app.services.batch_dispatch import dispatch_batch
11+
from app.services.template_loader import TemplateNotFoundError
12+
13+
14+
class _FakePrintService:
15+
def __init__(self, fail_at: dict[int, type[Exception]] | None = None):
16+
self.fail_at = fail_at or {}
17+
self.calls = 0
18+
19+
async def submit_print_job(self, req: PrintRequest):
20+
idx = self.calls
21+
self.calls += 1
22+
if idx in self.fail_at:
23+
raise self.fail_at[idx]("simulated")
24+
return str(uuid4())
25+
26+
27+
def _item(template_id="hangar-furniture-12mm"):
28+
return PrintRequest(template_id=template_id,
29+
data=RawLabelData(title="t", primary_id="p", qr_payload="q"))
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_dispatch_all_succeed():
34+
service = _FakePrintService()
35+
items = [_item() for _ in range(3)]
36+
job_ids, errors = await dispatch_batch(service, items)
37+
assert len(job_ids) == 3
38+
assert errors == []
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_dispatch_partial_failure_keeps_going():
43+
service = _FakePrintService(fail_at={1: TemplateNotFoundError})
44+
items = [_item(), _item("typo"), _item()]
45+
job_ids, errors = await dispatch_batch(service, items)
46+
assert len(job_ids) == 2
47+
assert len(errors) == 1
48+
assert errors[0].index == 1
49+
assert errors[0].error_code == "template_not_found"

0 commit comments

Comments
 (0)