|
@@ -132,7 +132,84 @@ def _format_sidecar_error(response: httpx.Response) -> str:
|
|
|
return (message or details or response.text)[:500]
|
|
return (message or details or response.text)[:500]
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
|
|
|
|
|
|
|
+def _transport_error_reason(exc: httpx.RequestError) -> str:
|
|
|
|
|
+ """Describe a transport failure, even when the exception carries no message.
|
|
|
|
|
+
|
|
|
|
|
+ Several ``httpx.RequestError`` subclasses are raised with no args, so
|
|
|
|
|
+ ``str(exc)`` is the empty string — which is how three lines of the #2802
|
|
|
|
|
+ reporter's support package came to read ``Slicer sidecar unreachable:``
|
|
|
|
|
+ with nothing after the colon. The class name is not much, but it
|
|
|
|
|
+ distinguishes a refused connection from a protocol error, and a log line
|
|
|
|
|
+ that names nothing is worth less than one that names the exception type.
|
|
|
|
|
+ """
|
|
|
|
|
+ return str(exc) or type(exc).__name__
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# How the sidecar says "your model is bigger than my cap", across versions.
|
|
|
|
|
+# Images built before the cap became configurable answer with multer's raw
|
|
|
|
|
+# ``LIMIT_FILE_SIZE`` text under a **500** — ``MulterError`` is not the
|
|
|
|
|
+# sidecar's ``AppError``, so its handler falls through to the default status —
|
|
|
|
|
+# while current ones send a 413 naming the limit and the env var that raises
|
|
|
|
|
+# it. Matching on text rather than status covers both, and matters because a
|
|
|
|
|
+# 500 otherwise reads as a slicer crash and sends people off tuning reverse
|
|
|
|
|
+# proxies that were never in the path (#2802).
|
|
|
|
|
+#
|
|
|
|
|
+# Deliberately specific: a proxy's own "413 Request Entity Too Large" must NOT
|
|
|
|
|
+# match, because that one really is fixed at the proxy and gets its own advice.
|
|
|
|
|
+_UPLOAD_TOO_LARGE_MARKERS = (
|
|
|
|
|
+ "file too large",
|
|
|
|
|
+ "upload limit",
|
|
|
|
|
+ "max_model_upload_mb",
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+# A sidecar that says which knob raises the cap is new enough to have one.
|
|
|
|
|
+# Older ones only ever emit multer's bare "File too large", and for those the
|
|
|
|
|
+# advice has to be "update the image" — there is no env var to set.
|
|
|
|
|
+_CONFIGURABLE_CAP_MARKERS = ("upload limit", "max_model_upload_mb")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _upload_size_rejection(response: httpx.Response, model_size_bytes: int | None) -> str | None:
|
|
|
|
|
+ """Return an explanation if the sidecar refused the upload as oversized.
|
|
|
|
|
+
|
|
|
|
|
+ The 500 case is matched strictly — the body has to be *only* multer's
|
|
|
|
|
+ message — because a 500 is also how a genuine CLI failure arrives, and
|
|
|
|
|
+ those must keep reaching the embedded-settings fallback. A CLI failure
|
|
|
|
|
+ always carries the slicer's stderr in ``details``, so it never reduces to
|
|
|
|
|
+ the bare string on its own.
|
|
|
|
|
+ """
|
|
|
|
|
+ detail = _format_sidecar_error(response)
|
|
|
|
|
+ lowered = detail.lower()
|
|
|
|
|
+ if response.status_code >= 500:
|
|
|
|
|
+ if lowered.strip() != "file too large":
|
|
|
|
|
+ return None
|
|
|
|
|
+ elif not any(marker in lowered for marker in _UPLOAD_TOO_LARGE_MARKERS):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ size = f"{model_size_bytes / (1024 * 1024):.0f} MB " if model_size_bytes else ""
|
|
|
|
|
+ # Shared preamble: both variants must rule out the layers people reach for
|
|
|
|
|
+ # first, because those are the ones that look like they should apply.
|
|
|
|
|
+ common = (
|
|
|
|
|
+ f"The slicer sidecar refused the {size}model file as too large. The limit lives inside "
|
|
|
|
|
+ "the sidecar container, so it is neither a Bambuddy setting nor a reverse-proxy one — "
|
|
|
|
|
+ "raising 'client_max_body_size' or a proxy body limit will not change it."
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if any(marker in lowered for marker in _CONFIGURABLE_CAP_MARKERS):
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"{common} Raise it by setting MAX_MODEL_UPLOAD_MB on the slicer-api service and "
|
|
|
|
|
+ f"restarting it. Sidecar said: {detail}"
|
|
|
|
|
+ )
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"{common} This sidecar image predates the configurable cap and is fixed at 100 MB — "
|
|
|
|
|
+ "update it with 'cd slicer-api/ && docker compose pull && docker compose up -d', which "
|
|
|
|
|
+ "raises the default and adds MAX_MODEL_UPLOAD_MB for going higher still. "
|
|
|
|
|
+ f"Sidecar said: {detail}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _handle_slice_response(
|
|
|
|
|
+ response: httpx.Response, *, export_3mf: bool, model_size_bytes: int | None = None
|
|
|
|
|
+) -> SliceResult:
|
|
|
"""Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
|
|
"""Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
|
|
|
|
|
|
|
|
Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
|
|
Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
|
|
@@ -151,6 +228,14 @@ def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> Sli
|
|
|
SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
|
|
SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
|
|
|
SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
|
|
SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
|
|
|
"""
|
|
"""
|
|
|
|
|
+ # Checked ahead of the status branches because the same rejection arrives
|
|
|
|
|
+ # as a 500 from older sidecars and a 413 from newer ones, and because
|
|
|
|
|
+ # raising SlicerInputError (rather than SlicerApiServerError) is what stops
|
|
|
|
|
+ # the library route retrying the identical oversized upload with embedded
|
|
|
|
|
+ # settings — a second 25-second conversion for a guaranteed same answer.
|
|
|
|
|
+ oversized = _upload_size_rejection(response, model_size_bytes)
|
|
|
|
|
+ if oversized:
|
|
|
|
|
+ raise SlicerInputError(oversized)
|
|
|
if response.status_code == 413:
|
|
if response.status_code == 413:
|
|
|
# A 413 almost never comes from the slicer itself — it's a reverse proxy
|
|
# A 413 almost never comes from the slicer itself — it's a reverse proxy
|
|
|
# (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
|
|
# (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
|
|
@@ -317,7 +402,7 @@ class SlicerApiService:
|
|
|
try:
|
|
try:
|
|
|
response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
|
|
response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
|
|
|
except httpx.RequestError as exc:
|
|
except httpx.RequestError as exc:
|
|
|
- raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
|
|
|
|
|
+ raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
|
|
if response.status_code >= 400:
|
|
if response.status_code >= 400:
|
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
|
|
|
return response.json()
|
|
return response.json()
|
|
@@ -358,7 +443,7 @@ class SlicerApiService:
|
|
|
timeout=15.0,
|
|
timeout=15.0,
|
|
|
)
|
|
)
|
|
|
except httpx.RequestError as exc:
|
|
except httpx.RequestError as exc:
|
|
|
- raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
|
|
|
|
|
+ raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
|
|
|
|
|
|
|
if response.status_code == 404:
|
|
if response.status_code == 404:
|
|
|
# Sidecar predates the endpoint. Not an error, and specifically not
|
|
# Sidecar predates the endpoint. Not an error, and specifically not
|
|
@@ -397,7 +482,7 @@ class SlicerApiService:
|
|
|
try:
|
|
try:
|
|
|
response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
|
|
response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
|
|
|
except httpx.RequestError as exc:
|
|
except httpx.RequestError as exc:
|
|
|
- raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
|
|
|
|
|
+ raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
|
|
if response.status_code >= 400:
|
|
if response.status_code >= 400:
|
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
|
|
|
return response.json()
|
|
return response.json()
|
|
@@ -530,7 +615,7 @@ class SlicerApiService:
|
|
|
try:
|
|
try:
|
|
|
return post_task.result()
|
|
return post_task.result()
|
|
|
except httpx.RequestError as exc:
|
|
except httpx.RequestError as exc:
|
|
|
- raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
|
|
|
|
|
+ raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
|
|
|
|
|
|
|
async def slice_with_profiles(
|
|
async def slice_with_profiles(
|
|
|
self,
|
|
self,
|
|
@@ -612,8 +697,9 @@ class SlicerApiService:
|
|
|
# and surfaces structured updates via on_progress. Uses a
|
|
# and surfaces structured updates via on_progress. Uses a
|
|
|
# short-tick poll (1s) since the slicer emits stage changes
|
|
# short-tick poll (1s) since the slicer emits stage changes
|
|
|
# several times per minute on complex models.
|
|
# several times per minute on complex models.
|
|
|
|
|
+ _log_slice_request(model_filename, model_bytes, plate=plate, profiles=len(filament_profile_jsons) + 2)
|
|
|
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
|
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
|
|
- return _handle_slice_response(response, export_3mf=export_3mf)
|
|
|
|
|
|
|
+ return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
|
|
|
|
|
|
|
|
async def slice_without_profiles(
|
|
async def slice_without_profiles(
|
|
|
self,
|
|
self,
|
|
@@ -668,8 +754,26 @@ class SlicerApiService:
|
|
|
# embedded-settings fallback path triggered by an Orca/Bambu CLI
|
|
# embedded-settings fallback path triggered by an Orca/Bambu CLI
|
|
|
# segfault on complex H2D models — both want to keep updating
|
|
# segfault on complex H2D models — both want to keep updating
|
|
|
# the user's toast through the slow operation.
|
|
# the user's toast through the slow operation.
|
|
|
|
|
+ _log_slice_request(model_filename, model_bytes, plate=plate, profiles=0)
|
|
|
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
|
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
|
|
- return _handle_slice_response(response, export_3mf=export_3mf)
|
|
|
|
|
|
|
+ return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _log_slice_request(filename: str, model_bytes: bytes, *, plate: int | None, profiles: int) -> None:
|
|
|
|
|
+ """Record what is being sent to the sidecar, size included.
|
|
|
|
|
+
|
|
|
|
|
+ Nothing used to log the payload size, so a support package from a slice
|
|
|
|
|
+ that failed on an upload cap looked identical to one that failed on a bad
|
|
|
|
|
+ profile — #2802 had to be sized by probing a sidecar by hand. One line per
|
|
|
|
|
+ slice is cheap next to the operation it describes.
|
|
|
|
|
+ """
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Slicing %s (%.1f MB) plate=%s with %d profile(s)",
|
|
|
|
|
+ filename,
|
|
|
|
|
+ len(model_bytes) / (1024 * 1024),
|
|
|
|
|
+ "all" if plate is None else plate,
|
|
|
|
|
+ profiles,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
|
|
def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
|