test_slicer_upload_size_rejection.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. """The sidecar's upload cap, reported as something the user can act on (#2802).
  2. The slicer sidecar bounds the size of the model it will accept. multer raises
  3. that rejection as a ``MulterError``, which is not the sidecar's ``AppError`` —
  4. so on every image built before the cap became configurable, the sidecar's error
  5. handler fell through to its default status and answered:
  6. HTTP 500 {"message": "File too large"}
  7. A 500 reads as "the slicer crashed". Bambuddy's one good message about request
  8. size lived behind ``if response.status_code == 413``, so it never fired, and the
  9. reporter of #2802 spent an evening setting ``MAX_FILE_SIZE``,
  10. ``BODY_PARSER_LIMIT`` and ``EXPRESS_PAYLOAD_LIMIT`` and stopping nginx — none of
  11. which the sidecar reads, on a proxy that was never in the path.
  12. Two things follow, and both are pinned here:
  13. - The rejection is recognised by its *text*, not its status, so it is handled
  14. the same whether the sidecar is old (500) or current (413).
  15. - It raises ``SlicerInputError`` rather than ``SlicerApiServerError``. That is
  16. what stops ``POST /library/files/{id}/slice`` retrying the identical
  17. oversized upload "with embedded settings" — a second 25-second 3MF
  18. conversion for a guaranteed-identical answer, which the reporter's log shows
  19. happening on every attempt.
  20. """
  21. import httpx
  22. import pytest
  23. from backend.app.services.slicer_api import (
  24. SlicerApiServerError,
  25. SlicerApiService,
  26. SlicerApiUnavailableError,
  27. SlicerInputError,
  28. _transport_error_reason,
  29. )
  30. SLICE_ARGS = {
  31. "model_bytes": b"x" * (3 * 1024 * 1024),
  32. "model_filename": "0399 Bidoof.3mf",
  33. "printer_profile_json": "{}",
  34. "process_profile_json": "{}",
  35. "filament_profile_jsons": ["{}"],
  36. }
  37. def _service(handler) -> SlicerApiService:
  38. client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
  39. return SlicerApiService("http://sidecar:3001", client=client)
  40. def _responder(status_code: int, payload: dict):
  41. def handler(request: httpx.Request) -> httpx.Response:
  42. return httpx.Response(status_code, json=payload)
  43. return handler
  44. class TestOversizeUploadIsRecognised:
  45. @pytest.mark.asyncio
  46. async def test_a_500_file_too_large_is_treated_as_bad_input(self):
  47. """The exact shape an un-updated sidecar returns."""
  48. svc = _service(_responder(500, {"message": "File too large"}))
  49. with pytest.raises(SlicerInputError) as excinfo:
  50. await svc.slice_with_profiles(**SLICE_ARGS)
  51. assert "too large" in str(excinfo.value)
  52. @pytest.mark.asyncio
  53. async def test_a_413_from_a_current_sidecar_is_handled_the_same(self):
  54. """Once the sidecar maps MulterError properly it sends 413 instead."""
  55. svc = _service(
  56. _responder(
  57. 413,
  58. {
  59. "message": "The model file exceeds this slicer's 512 MB upload limit.",
  60. "details": "Raise it by setting MAX_MODEL_UPLOAD_MB.",
  61. },
  62. )
  63. )
  64. with pytest.raises(SlicerInputError):
  65. await svc.slice_with_profiles(**SLICE_ARGS)
  66. @pytest.mark.asyncio
  67. async def test_it_is_not_a_server_error(self):
  68. """The distinction the retry logic in library.py branches on.
  69. ``SlicerApiServerError`` is the "the CLI fell over, try the other
  70. request shape" signal. An upload the sidecar never accepted is not
  71. that, and retrying it uploads the same too-big file again.
  72. """
  73. svc = _service(_responder(500, {"message": "File too large"}))
  74. with pytest.raises(SlicerInputError):
  75. await svc.slice_with_profiles(**SLICE_ARGS)
  76. # Belt and braces: SlicerInputError must not be a subclass of the type
  77. # the fallback catches, or the branch above is decorative.
  78. assert not issubclass(SlicerInputError, SlicerApiServerError)
  79. class TestTheMessageIsActionable:
  80. @pytest.mark.asyncio
  81. async def test_it_names_the_model_size(self):
  82. """Support packages carried no size at all; #2802 had to be probed."""
  83. svc = _service(_responder(500, {"message": "File too large"}))
  84. with pytest.raises(SlicerInputError) as excinfo:
  85. await svc.slice_with_profiles(**SLICE_ARGS)
  86. assert "3 MB" in str(excinfo.value)
  87. @pytest.mark.asyncio
  88. async def test_it_rules_out_the_layers_the_reporter_tried(self):
  89. """Naming the wrong knobs is the point: they were tried first."""
  90. svc = _service(_responder(500, {"message": "File too large"}))
  91. with pytest.raises(SlicerInputError) as excinfo:
  92. await svc.slice_with_profiles(**SLICE_ARGS)
  93. message = str(excinfo.value)
  94. assert "reverse-proxy" in message
  95. assert "client_max_body_size" in message
  96. @pytest.mark.asyncio
  97. async def test_an_old_sidecar_is_told_to_update_not_to_set_a_variable(self):
  98. """There is no env var to set on an image that predates the cap.
  99. Telling that user to set MAX_MODEL_UPLOAD_MB would send them round the
  100. loop the reporter already did: change a setting, restart, no effect.
  101. """
  102. svc = _service(_responder(500, {"message": "File too large"}))
  103. with pytest.raises(SlicerInputError) as excinfo:
  104. await svc.slice_with_profiles(**SLICE_ARGS)
  105. message = str(excinfo.value)
  106. assert "docker compose pull" in message
  107. assert "100 MB" in message
  108. @pytest.mark.asyncio
  109. async def test_the_update_command_names_the_service(self):
  110. """A bare ``docker compose pull`` does not update the Bambu sidecar.
  111. ``bambu-studio-api`` is declared with ``profiles: [bambu]``, and compose
  112. skips profile-gated services unless the profile is enabled or the
  113. service is named. The advice this message used to give was therefore a
  114. no-op for Bambu Studio users -- they pulled, saw "up to date", restarted
  115. into the same 100 MB image and came back to the issue (#2802).
  116. Both commands are checked: pulling the right image is useless if the
  117. ``up -d`` that follows leaves the old container running.
  118. """
  119. svc = _service(_responder(500, {"message": "File too large"}))
  120. with pytest.raises(SlicerInputError) as excinfo:
  121. await svc.slice_with_profiles(**SLICE_ARGS)
  122. message = str(excinfo.value)
  123. assert "docker compose pull orca-slicer-api" in message
  124. assert "docker compose up -d orca-slicer-api" in message
  125. assert "bambu-studio-api" in message
  126. # The bare forms must not appear at all -- a reader who copies the first
  127. # command they see must not get the one that silently does nothing.
  128. assert "docker compose pull &&" not in message
  129. assert "docker compose up -d'" not in message
  130. @pytest.mark.asyncio
  131. async def test_a_current_sidecar_is_told_which_variable_to_set(self):
  132. """Once the image is current, the fix is one env var, not another pull."""
  133. svc = _service(
  134. _responder(
  135. 413,
  136. {"message": "The model file exceeds this slicer's 512 MB upload limit."},
  137. )
  138. )
  139. with pytest.raises(SlicerInputError) as excinfo:
  140. await svc.slice_with_profiles(**SLICE_ARGS)
  141. message = str(excinfo.value)
  142. assert "MAX_MODEL_UPLOAD_MB" in message
  143. assert "docker compose pull" not in message
  144. @pytest.mark.asyncio
  145. async def test_it_keeps_what_the_sidecar_said(self):
  146. """Never swallow the upstream text — it identifies the sidecar version."""
  147. svc = _service(_responder(413, {"message": "The model file exceeds this slicer's 256 MB upload limit."}))
  148. with pytest.raises(SlicerInputError) as excinfo:
  149. await svc.slice_with_profiles(**SLICE_ARGS)
  150. assert "256 MB" in str(excinfo.value)
  151. class TestOtherFailuresAreUnaffected:
  152. @pytest.mark.asyncio
  153. async def test_an_ordinary_cli_failure_is_still_a_server_error(self):
  154. """The embedded-settings fallback must keep working for real crashes."""
  155. svc = _service(
  156. _responder(
  157. 500,
  158. {
  159. "message": "Slicing failed with error from slicer",
  160. "details": "Slicer process failed (exit code 250)",
  161. },
  162. )
  163. )
  164. with pytest.raises(SlicerApiServerError):
  165. await svc.slice_with_profiles(**SLICE_ARGS)
  166. @pytest.mark.asyncio
  167. async def test_a_cli_error_that_merely_mentions_a_large_file_is_not_hijacked(self):
  168. """A 500 only counts as an upload rejection if that is all it says.
  169. The slicer's own diagnostics land in ``details``, and treating one of
  170. those as a size rejection would rob it of the embedded-settings retry
  171. that exists to recover from CLI failures.
  172. """
  173. svc = _service(
  174. _responder(
  175. 500,
  176. {
  177. "message": "Slicing failed with error from slicer",
  178. "details": "stderr: output file too large to write",
  179. },
  180. )
  181. )
  182. with pytest.raises(SlicerApiServerError):
  183. await svc.slice_with_profiles(**SLICE_ARGS)
  184. @pytest.mark.asyncio
  185. async def test_a_proxy_413_still_names_the_proxy(self):
  186. """A 413 that is *not* the sidecar's own cap is a proxy body limit.
  187. Those really are fixed with ``client_max_body_size``, so that advice
  188. has to survive — the new branch must not swallow every 413.
  189. """
  190. svc = _service(_responder(413, {"message": "<html>413 Request Entity Too Large</html>"}))
  191. with pytest.raises(SlicerInputError) as excinfo:
  192. await svc.slice_with_profiles(**SLICE_ARGS)
  193. assert "client_max_body_size" in str(excinfo.value)
  194. class TestTransportErrorsAlwaysNameSomething:
  195. """Three lines of the #2802 support package read "unreachable: " and stop."""
  196. def test_an_exception_with_no_message_falls_back_to_its_type(self):
  197. assert _transport_error_reason(httpx.ConnectError("")) == "ConnectError"
  198. def test_a_real_message_is_preferred(self):
  199. assert _transport_error_reason(httpx.ConnectError("All connection attempts failed")) == (
  200. "All connection attempts failed"
  201. )
  202. @pytest.mark.asyncio
  203. async def test_the_slice_path_never_reports_an_empty_reason(self):
  204. def handler(request: httpx.Request) -> httpx.Response:
  205. raise httpx.ReadError("")
  206. svc = _service(handler)
  207. with pytest.raises(SlicerApiUnavailableError) as excinfo:
  208. await svc.slice_with_profiles(**SLICE_ARGS)
  209. assert str(excinfo.value).strip().endswith("ReadError")