test_ftp_cooloff_retry_budget_2898.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """A dispatch must not spend its retries on a cool-off that outlives them (#2898).
  2. ``BambuFTPClient.connect`` refuses to open a socket for 300s after a TLS
  3. handshake failure (#2780). That gate was written for the background sweeps --
  4. the post-print 3MF, cover and timelapse fetches, which walk ~110 candidate
  5. paths against one wedged printer and have nobody waiting on them.
  6. It sat inside ``connect``, so it applied to print dispatch too, which wants the
  7. opposite. On a 10-printer farm one handshake failure took out three queued
  8. jobs: the pre-upload delete armed the cool-off, all four upload attempts were
  9. then answered from the gate 2s apart without a socket being opened, and the
  10. next two jobs for that printer failed the same way inside the same window.
  11. The split these tests pin: work that is bounded and user-initiated (a dispatch
  12. is one delete plus at most four upload attempts) opts out; everything else
  13. keeps #2780's behaviour exactly. Sockets are counted rather than inferred,
  14. because "returned False" looks identical either way -- which is what made the
  15. original report a log dive.
  16. """
  17. import logging
  18. import ssl
  19. from unittest.mock import MagicMock, patch
  20. import pytest
  21. from backend.app.services import bambu_ftp
  22. from backend.app.services.bambu_ftp import (
  23. BambuFTPClient,
  24. DeleteResult,
  25. delete_file_async,
  26. upload_file_async,
  27. with_ftp_retry,
  28. )
  29. pytestmark = pytest.mark.unit
  30. IP = "192.168.50.142" # the P2S from the report
  31. LOGGER = "backend.app.services.bambu_ftp"
  32. @pytest.fixture(autouse=True)
  33. def _clean_cooloff():
  34. BambuFTPClient._handshake_blocked_until.clear()
  35. BambuFTPClient._handshake_skip_logged.clear()
  36. BambuFTPClient._mode_cache.clear()
  37. yield
  38. BambuFTPClient._handshake_blocked_until.clear()
  39. BambuFTPClient._handshake_skip_logged.clear()
  40. BambuFTPClient._mode_cache.clear()
  41. @pytest.fixture()
  42. def refusing_printer():
  43. """Answers port 990 with something that is not TLS, every time.
  44. Yields the transport mock; ``transport.connect.call_count`` is the number
  45. of times we actually went near the printer, which is the whole question
  46. here.
  47. """
  48. transport = MagicMock()
  49. transport.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
  50. with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=transport):
  51. yield transport
  52. def _arm(ip=IP):
  53. """Put *ip* into the cool-off the way a real handshake failure would."""
  54. BambuFTPClient._handshake_blocked_until[ip] = bambu_ftp.time.monotonic() + bambu_ftp._HANDSHAKE_COOLOFF_SECONDS
  55. assert BambuFTPClient.handshake_blocked(ip) is True
  56. # ---------------------------------------------------------------------------
  57. # The gate itself
  58. # ---------------------------------------------------------------------------
  59. class TestConnectHonoursTheOptOut:
  60. def test_the_default_still_refuses_to_open_a_socket(self, refusing_printer):
  61. """#2780's protection is the default and must stay untouched."""
  62. _arm()
  63. assert BambuFTPClient(IP, "12345678", printer_model="P2S").connect() is False
  64. assert refusing_printer.connect.call_count == 0
  65. def test_an_exempt_client_reaches_the_printer(self, refusing_printer):
  66. _arm()
  67. client = BambuFTPClient(IP, "12345678", printer_model="P2S", respect_handshake_cooloff=False)
  68. assert client.connect() is False # the printer is still broken...
  69. assert refusing_printer.connect.call_count == 1 # ...but we found that out ourselves
  70. def test_the_opt_out_does_not_leak_to_the_next_client(self, refusing_printer):
  71. """The flag is per client, not a global switch someone can leave on."""
  72. _arm()
  73. BambuFTPClient(IP, "12345678", respect_handshake_cooloff=False).connect()
  74. refusing_printer.connect.reset_mock()
  75. BambuFTPClient(IP, "12345678").connect()
  76. assert refusing_printer.connect.call_count == 0
  77. def test_the_skip_says_why_at_a_level_operators_see(self, caplog):
  78. """The reason-free WARNING is what made this a log dive.
  79. Every other ``connect`` failure path names its cause; this one logged
  80. at DEBUG, so at default level four identical "FTP connection failed"
  81. lines gave no hint that nothing had been sent.
  82. """
  83. _arm()
  84. with caplog.at_level(logging.WARNING, logger=LOGGER):
  85. assert BambuFTPClient(IP, "12345678").connect() is False
  86. messages = [r.getMessage() for r in caplog.records]
  87. assert any("cooling off" in m and IP in m for m in messages), messages
  88. # And it has to be legible as "we did nothing", not as a network error.
  89. assert any("Nothing was sent to the printer" in m for m in messages), messages
  90. def test_it_says_it_once_per_cooloff_and_not_once_per_attempt(self, caplog):
  91. """Raising this to WARNING must not re-create the flood #2780 stopped.
  92. Not every caller is gated: downloading a ZIP of files the user picked
  93. walks the whole selection, so 200 files would otherwise repeat the same
  94. sentence 200 times.
  95. """
  96. _arm()
  97. with caplog.at_level(logging.DEBUG, logger=LOGGER):
  98. for _ in range(200):
  99. BambuFTPClient(IP, "12345678").connect()
  100. warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
  101. assert len(warnings) == 1, [r.getMessage() for r in warnings]
  102. # Still recoverable at DEBUG for anyone reading a support bundle.
  103. assert sum("still cooling off" in r.getMessage() for r in caplog.records) == 199
  104. def test_a_fresh_handshake_failure_is_announced_again(self, caplog):
  105. """Once per cool-off, not once per process.
  106. A printer that recovers and fails again is a new event, and silence
  107. would be the DEBUG-level problem this fix set out to remove.
  108. """
  109. _arm()
  110. with caplog.at_level(logging.WARNING, logger=LOGGER):
  111. BambuFTPClient(IP, "12345678").connect()
  112. _arm() # a later handshake failure pushes the deadline out
  113. BambuFTPClient(IP, "12345678").connect()
  114. warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
  115. assert len(warnings) == 2, [r.getMessage() for r in warnings]
  116. # ---------------------------------------------------------------------------
  117. # The retry loop
  118. # ---------------------------------------------------------------------------
  119. class TestRetryLoopStopsOnAnArmedCooloff:
  120. async def _run(self, *, cooloff_ip, calls):
  121. async def op():
  122. calls.append(1)
  123. _arm() # the first attempt is what arms it, as in the report
  124. return False
  125. return await with_ftp_retry(
  126. op,
  127. max_retries=3,
  128. retry_delay=0.01,
  129. operation_name="Download 3MF",
  130. cooloff_ip=cooloff_ip,
  131. )
  132. async def test_a_respecting_caller_stops_after_the_attempt_that_armed_it(self, caplog):
  133. calls = []
  134. with caplog.at_level(logging.WARNING, logger=LOGGER):
  135. assert await self._run(cooloff_ip=IP, calls=calls) is None
  136. assert len(calls) == 1
  137. messages = [r.getMessage() for r in caplog.records]
  138. assert any("stopping after attempt 1/4" in m for m in messages), messages
  139. # The tally has to match what was really tried. "failed after 4
  140. # attempts" for one attempt is how this read as a network problem.
  141. assert any("failed after 1 attempts" in m for m in messages), messages
  142. async def test_a_caller_without_the_ip_keeps_its_full_budget(self):
  143. """Dispatch ignores the cool-off, so the loop must not stop on it.
  144. Stopping here would undo the exemption from the other end: the
  145. attempts would still be refused, just by the retry loop instead of by
  146. ``connect``.
  147. """
  148. calls = []
  149. assert await self._run(cooloff_ip=None, calls=calls) is None
  150. assert len(calls) == 4
  151. # ---------------------------------------------------------------------------
  152. # The reported failure, end to end
  153. # ---------------------------------------------------------------------------
  154. class TestDispatchKeepsItsAttempts:
  155. async def test_every_upload_attempt_reaches_the_printer(self, refusing_printer, tmp_path):
  156. """The trace from the report: cool-off armed, then four dead attempts.
  157. The reporter's evidence is that the handshake failure is transient --
  158. a manual connect a second later completes cleanly -- so the retry the
  159. gate suppressed is precisely the retry that would have worked.
  160. """
  161. _arm()
  162. local = tmp_path / "job.gcode.3mf"
  163. local.write_bytes(b"x" * 1024)
  164. result = await with_ftp_retry(
  165. upload_file_async,
  166. IP,
  167. "12345678",
  168. local,
  169. "/job.gcode.3mf",
  170. timeout=5.0,
  171. printer_model="P2S",
  172. respect_handshake_cooloff=False,
  173. max_retries=3,
  174. retry_delay=0.01,
  175. operation_name="Upload print to Bambulab P2S-4",
  176. )
  177. assert result is None
  178. assert refusing_printer.connect.call_count == 4
  179. async def test_without_the_exemption_the_same_upload_touches_nothing(self, refusing_printer, tmp_path):
  180. """Mutation guard: revert the exemption and the test above must fail.
  181. Without this, ``call_count == 4`` above would pass for the wrong reason
  182. if the cool-off were ever simply removed.
  183. """
  184. _arm()
  185. local = tmp_path / "job.gcode.3mf"
  186. local.write_bytes(b"x" * 1024)
  187. result = await with_ftp_retry(
  188. upload_file_async,
  189. IP,
  190. "12345678",
  191. local,
  192. "/job.gcode.3mf",
  193. timeout=5.0,
  194. printer_model="P2S",
  195. max_retries=3,
  196. retry_delay=0.01,
  197. operation_name="Upload print to Bambulab P2S-4",
  198. )
  199. assert result is None
  200. assert refusing_printer.connect.call_count == 0
  201. async def test_the_pre_upload_delete_is_exempt_too(self, refusing_printer):
  202. """In the report's trace the delete is what armed the cool-off.
  203. It runs 8ms before the upload's first attempt, so leaving it gated
  204. would keep one whole dispatch's worth of the problem in place.
  205. """
  206. _arm()
  207. result = await delete_file_async(
  208. IP, "12345678", "/job.gcode.3mf", printer_model="P2S", respect_handshake_cooloff=False
  209. )
  210. assert result is DeleteResult.FAILED
  211. assert refusing_printer.connect.call_count == 1
  212. async def test_a_background_download_is_still_gated(self, refusing_printer):
  213. """The sweeps keep #2780 exactly: nobody is waiting, so back off."""
  214. _arm()
  215. assert await bambu_ftp.download_file_bytes_async(IP, "12345678", "/timelapse/a.mp4") is None
  216. assert refusing_printer.connect.call_count == 0