test_scheduler_upload_requeue_3210.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. """A failed upload must not eat the queue (#3210).
  2. The reporter queued 20 jobs for "any P2S". One P2S's file service was out of
  3. connection slots -- it answered port 990 with ``421 There are too many
  4. connections from your internet address`` -- so every upload to it failed. Each
  5. failure marked the item ``failed``, which left that printer idle, so the next
  6. pass handed it the next item. One item died about every nine seconds: 43 jobs
  7. gone in ten minutes, none printed on that printer.
  8. Two changes, pinned here:
  9. * An upload whose file never reached the printer (handshake refused, cool-off,
  10. timeout, dropped connection) puts the item back in the queue. Failures that
  11. would repeat on every retry -- a rejected access code, a full card -- still
  12. fail it.
  13. * The printer is out of dispatch for a backoff window, so nothing else is
  14. sent to it, and the item that was put back waits there with a reason.
  15. """
  16. import asyncio
  17. import time
  18. from contextlib import ExitStack, asynccontextmanager
  19. from pathlib import Path
  20. from types import SimpleNamespace
  21. from unittest.mock import AsyncMock, MagicMock, patch
  22. import pytest
  23. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  24. import backend.app.models # noqa: F401 - populate Base.metadata
  25. import backend.app.services.archive as archive_module
  26. import backend.app.services.print_scheduler as scheduler_module
  27. from backend.app.core.database import Base
  28. from backend.app.models.archive import PrintArchive
  29. from backend.app.models.print_queue import PrintQueueItem
  30. from backend.app.models.printer import Printer
  31. from backend.app.services.bambu_ftp import BambuFTPClient, FtpFailure, FtpFailureKind, UploadCancelled
  32. from backend.app.services.print_scheduler import (
  33. UPLOAD_FAILURE_BACKOFF_MAX_SECONDS,
  34. UPLOAD_FAILURE_BACKOFF_SECONDS,
  35. PrintScheduler,
  36. )
  37. pytestmark = pytest.mark.unit
  38. REFUSING_IP = "10.0.0.8"
  39. HEALTHY_IP = "10.0.0.9"
  40. @pytest.fixture
  41. async def farm(tmp_path):
  42. """Two printers -- one whose file service refuses everything -- and an item factory."""
  43. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  44. async with engine.begin() as conn:
  45. await conn.run_sync(Base.metadata.create_all)
  46. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  47. base_dir = tmp_path / "farm"
  48. (base_dir / "archives").mkdir(parents=True, exist_ok=True)
  49. async with session_maker() as db:
  50. refusing = Printer(
  51. name="P2S-8", serial_number="S8", ip_address=REFUSING_IP, access_code="12345678", model="P2S"
  52. )
  53. healthy = Printer(name="P2S-9", serial_number="S9", ip_address=HEALTHY_IP, access_code="12345678", model="P2S")
  54. db.add_all([refusing, healthy])
  55. await db.commit()
  56. ids = SimpleNamespace(refusing=refusing.id, healthy=healthy.id)
  57. counter = iter(range(1000))
  58. async def add_item(*, printer_id: int | None = None, target_model: str | None = None) -> int:
  59. n = next(counter)
  60. async with session_maker() as db:
  61. archive_rel = Path("archives") / f"job-{n}.3mf"
  62. (base_dir / archive_rel).write_bytes(b"archive payload")
  63. archive = PrintArchive(
  64. printer_id=printer_id,
  65. filename=f"job-{n}.3mf",
  66. file_path=str(archive_rel),
  67. file_size=15,
  68. status="completed",
  69. )
  70. db.add(archive)
  71. await db.flush()
  72. item = PrintQueueItem(
  73. printer_id=printer_id,
  74. target_model=target_model,
  75. archive_id=archive.id,
  76. status="pending",
  77. position=n,
  78. )
  79. db.add(item)
  80. await db.commit()
  81. return item.id
  82. try:
  83. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids, add_item=add_item)
  84. finally:
  85. await engine.dispose()
  86. def _upload_refused_by(ip: str, failure: FtpFailure):
  87. """An upload that fails with *failure* on *ip* and succeeds everywhere else."""
  88. async def _upload(ip_address, *_args, **kwargs):
  89. if ip_address != ip:
  90. return True
  91. if kwargs.get("failure") is not None:
  92. kwargs["failure"].failure = failure
  93. return False
  94. return _upload
  95. @asynccontextmanager
  96. async def _scheduler(
  97. ctx,
  98. upload,
  99. *,
  100. busy: set[int] | None = None,
  101. scheduler: PrintScheduler | None = None,
  102. waiting_notify: AsyncMock | None = None,
  103. ):
  104. """A scheduler wired to *ctx*'s database, with every printer idle unless in *busy*.
  105. Pass the same *waiting_notify* to several passes to count notifications
  106. across them.
  107. """
  108. scheduler = scheduler or PrintScheduler()
  109. busy = busy if busy is not None else set()
  110. failed_notify = AsyncMock()
  111. waiting_notify = waiting_notify or AsyncMock()
  112. def _real_spawn(coro, *, name=None):
  113. return asyncio.create_task(coro, name=name)
  114. patches = [
  115. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  116. patch.object(archive_module.settings, "base_dir", ctx.base_dir),
  117. patch.object(archive_module.settings, "archive_dir", ctx.base_dir / "archive"),
  118. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  119. patch("backend.app.core.database.async_session", ctx.session_maker),
  120. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  121. patch(
  122. "backend.app.services.print_scheduler.printer_manager.get_status",
  123. MagicMock(return_value=SimpleNamespace(state="IDLE", subtask_id=None, gcode_file=None, raw_data={})),
  124. ),
  125. patch(
  126. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  127. MagicMock(return_value=False),
  128. ),
  129. patch("backend.app.services.print_scheduler.printer_manager.start_print", MagicMock(return_value=True)),
  130. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  131. patch("backend.app.services.print_scheduler.upload_file_async", upload),
  132. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  133. patch(
  134. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  135. AsyncMock(return_value=(False, 0, 0, 1.0)),
  136. ),
  137. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  138. patch("backend.app.services.print_scheduler.spawn_background_task", _real_spawn),
  139. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  140. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", failed_notify),
  141. patch("backend.app.services.notification_service.notification_service.on_queue_job_assigned", AsyncMock()),
  142. patch("backend.app.services.notification_service.notification_service.on_queue_job_waiting", waiting_notify),
  143. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  144. patch.object(scheduler, "_is_printer_idle", MagicMock(side_effect=lambda pid, *_a, **_k: pid not in busy)),
  145. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  146. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  147. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  148. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  149. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  150. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  151. patch.object(scheduler, "_watchdog_print_start", AsyncMock()),
  152. ]
  153. with ExitStack() as stack:
  154. for patcher in patches:
  155. stack.enter_context(patcher)
  156. scheduler.failed_notify = failed_notify
  157. yield scheduler
  158. tasks = [task for (task, _pid) in scheduler._inflight.values()]
  159. if tasks:
  160. await asyncio.gather(*tasks, return_exceptions=True)
  161. async def _item(ctx, item_id: int) -> PrintQueueItem:
  162. async with ctx.session_maker() as db:
  163. return await db.get(PrintQueueItem, item_id)
  164. HANDSHAKE = FtpFailure(FtpFailureKind.HANDSHAKE, "WRONG_VERSION_NUMBER (printer answered in cleartext: 421 ...)")
  165. # ---------------------------------------------------------------------------
  166. # What one failed upload does to its item
  167. # ---------------------------------------------------------------------------
  168. class TestOneFailedUpload:
  169. @pytest.mark.asyncio
  170. @pytest.mark.parametrize(
  171. "kind",
  172. [FtpFailureKind.HANDSHAKE, FtpFailureKind.COOLOFF, FtpFailureKind.TIMEOUT, FtpFailureKind.NETWORK],
  173. )
  174. async def test_a_file_that_never_arrived_keeps_the_item(self, farm, kind):
  175. item_id = await farm.add_item(printer_id=farm.ids.refusing)
  176. upload = _upload_refused_by(REFUSING_IP, FtpFailure(kind, "detail"))
  177. async with _scheduler(farm, upload) as scheduler:
  178. await scheduler.check_queue()
  179. item = await _item(farm, item_id)
  180. assert item.status == "pending"
  181. assert item.printer_id == farm.ids.refusing
  182. assert item.error_message is None
  183. assert item.dispatch_attempts == 0, "the start-watchdog budget is for a printer that took the file"
  184. scheduler.failed_notify.assert_not_awaited()
  185. assert farm.ids.refusing in scheduler._upload_backoff
  186. @pytest.mark.asyncio
  187. @pytest.mark.parametrize(
  188. "failure",
  189. [
  190. FtpFailure(FtpFailureKind.AUTH, "530 Login incorrect.", "530"),
  191. FtpFailure(FtpFailureKind.STORAGE, "553 Could not create file.", "553"),
  192. FtpFailure(FtpFailureKind.NOT_FOUND, "550 Permission denied.", "550"),
  193. FtpFailure(FtpFailureKind.UNKNOWN, "500 what"),
  194. None,
  195. ],
  196. ids=["auth", "storage", "not_found", "unknown", "unreported"],
  197. )
  198. async def test_a_failure_that_would_repeat_still_fails_the_item(self, farm, failure):
  199. """Retrying a wrong access code or a full card every five minutes helps no one."""
  200. item_id = await farm.add_item(printer_id=farm.ids.refusing)
  201. async def upload(*_args, **kwargs):
  202. if failure is not None:
  203. kwargs["failure"].failure = failure
  204. return False
  205. async with _scheduler(farm, upload) as scheduler:
  206. await scheduler.check_queue()
  207. item = await _item(farm, item_id)
  208. assert item.status == "failed"
  209. assert item.error_message
  210. scheduler.failed_notify.assert_awaited_once()
  211. assert farm.ids.refusing not in scheduler._upload_backoff
  212. @pytest.mark.asyncio
  213. async def test_an_upload_that_overran_its_deadline_still_fails(self, farm):
  214. """A link too slow to finish would be just as slow next time (#2529)."""
  215. item_id = await farm.add_item(printer_id=farm.ids.refusing)
  216. async def upload(*_args, **kwargs):
  217. kwargs["failure"].failure = FtpFailure(FtpFailureKind.TIMEOUT, "deadline")
  218. raise UploadCancelled("too slow")
  219. async with _scheduler(farm, upload) as scheduler:
  220. await scheduler.check_queue()
  221. assert (await _item(farm, item_id)).status == "failed"
  222. @pytest.mark.asyncio
  223. async def test_a_cancel_during_the_upload_is_not_undone(self, farm):
  224. """The row is never written back to pending, so a cancel that won stays won."""
  225. item_id = await farm.add_item(printer_id=farm.ids.refusing)
  226. async def upload(*_args, **kwargs):
  227. async with farm.session_maker() as other:
  228. row = await other.get(PrintQueueItem, item_id)
  229. row.status = "cancelled"
  230. await other.commit()
  231. kwargs["failure"].failure = HANDSHAKE
  232. return False
  233. async with _scheduler(farm, upload) as scheduler:
  234. await scheduler.check_queue()
  235. assert (await _item(farm, item_id)).status == "cancelled"
  236. # ---------------------------------------------------------------------------
  237. # The report: a refusing printer must not drain the queue
  238. # ---------------------------------------------------------------------------
  239. class TestTheQueueIsNotDrained:
  240. @pytest.mark.asyncio
  241. async def test_any_model_items_stop_going_to_the_refusing_printer(self, farm):
  242. """#3210's shape: "any P2S" items, one P2S refusing every upload.
  243. Pass 1 sends one item to each printer; the refusing one keeps its item.
  244. Pass 2 has the healthy printer busy printing and the refusing one in
  245. backoff, so the rest wait instead of being fed to it one by one.
  246. """
  247. ids = [await farm.add_item(target_model="P2S") for _ in range(5)]
  248. upload = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  249. scheduler = PrintScheduler()
  250. async with _scheduler(farm, upload, scheduler=scheduler):
  251. await scheduler.check_queue()
  252. async with _scheduler(farm, upload, scheduler=scheduler, busy={farm.ids.healthy}):
  253. for _ in range(3):
  254. await scheduler.check_queue()
  255. items = [await _item(farm, i) for i in ids]
  256. assert [i.status for i in items].count("failed") == 0, [(i.id, i.status, i.error_message) for i in items]
  257. assert [i.status for i in items].count("printing") == 1
  258. held = [i for i in items if i.printer_id == farm.ids.refusing]
  259. assert len(held) == 1, "exactly one item waits on the refusing printer"
  260. assert held[0].status == "pending"
  261. waiting = [i for i in items if i.status == "pending" and i.printer_id is None]
  262. assert len(waiting) == 3, "the rest stay unassigned, free for any printer that comes free"
  263. @pytest.mark.asyncio
  264. async def test_the_item_kept_on_the_printer_says_why(self, farm):
  265. item_id = await farm.add_item(printer_id=farm.ids.refusing)
  266. upload = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  267. scheduler = PrintScheduler()
  268. async with _scheduler(farm, upload, scheduler=scheduler):
  269. await scheduler.check_queue()
  270. async with _scheduler(farm, upload, scheduler=scheduler):
  271. await scheduler.check_queue()
  272. item = await _item(farm, item_id)
  273. assert item.status == "pending"
  274. assert item.waiting_reason == "P2S-8 is not accepting files — Bambuddy will retry automatically"
  275. @pytest.mark.asyncio
  276. async def test_the_printer_is_tried_again_once_the_backoff_ends(self, farm):
  277. item_id = await farm.add_item(printer_id=farm.ids.refusing)
  278. scheduler = PrintScheduler()
  279. async with _scheduler(farm, _upload_refused_by(REFUSING_IP, HANDSHAKE), scheduler=scheduler):
  280. await scheduler.check_queue()
  281. retry_at = scheduler._upload_backoff[farm.ids.refusing]
  282. assert retry_at - time.monotonic() == pytest.approx(UPLOAD_FAILURE_BACKOFF_SECONDS, abs=5)
  283. # The printer recovered and the window has passed.
  284. scheduler._upload_backoff[farm.ids.refusing] = time.monotonic() - 1
  285. async with _scheduler(farm, AsyncMock(return_value=True), scheduler=scheduler):
  286. await scheduler.check_queue()
  287. assert (await _item(farm, item_id)).status == "printing"
  288. assert farm.ids.refusing not in scheduler._upload_backoff
  289. # ---------------------------------------------------------------------------
  290. # A printer that stays broken: retries thin out, and say so once
  291. # ---------------------------------------------------------------------------
  292. def _expire_backoff(scheduler: PrintScheduler, printer_id: int) -> None:
  293. """Jump past the printer's backoff window, as if the time had passed."""
  294. scheduler._upload_backoff[printer_id] = time.monotonic() - 1
  295. def _remaining(scheduler: PrintScheduler, printer_id: int) -> float:
  296. return scheduler._upload_backoff[printer_id] - time.monotonic()
  297. async def _finish(ctx, scheduler: PrintScheduler, item_id: int) -> None:
  298. """Mark a dispatched item's print done, so its printer can take the next one."""
  299. async with ctx.session_maker() as db:
  300. row = await db.get(PrintQueueItem, item_id)
  301. assert row.status == "printing"
  302. row.status = "completed"
  303. await db.commit()
  304. scheduler._release_dispatch_hold(row.printer_id)
  305. class TestAPrinterThatStaysBroken:
  306. @pytest.mark.asyncio
  307. async def test_each_refusal_in_a_row_doubles_the_wait_up_to_the_cap(self, farm):
  308. """#3210's printer refused for about 40 hours.
  309. At a flat five minutes that is ~480 retries, each one a preheat cycle
  310. where preheat is on, five connection attempts and a page of log.
  311. """
  312. await farm.add_item(printer_id=farm.ids.refusing)
  313. upload = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  314. scheduler = PrintScheduler()
  315. waits = []
  316. for _ in range(6):
  317. async with _scheduler(farm, upload, scheduler=scheduler):
  318. await scheduler.check_queue()
  319. waits.append(_remaining(scheduler, farm.ids.refusing))
  320. _expire_backoff(scheduler, farm.ids.refusing)
  321. expected = [300, 600, 1200, 2400, 3600, 3600]
  322. assert expected[0] == UPLOAD_FAILURE_BACKOFF_SECONDS
  323. assert expected[-1] == UPLOAD_FAILURE_BACKOFF_MAX_SECONDS
  324. assert waits == [pytest.approx(w, abs=5) for w in expected]
  325. @pytest.mark.asyncio
  326. async def test_a_successful_upload_resets_the_wait(self, farm):
  327. first = await farm.add_item(printer_id=farm.ids.refusing)
  328. refused = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  329. scheduler = PrintScheduler()
  330. for _ in range(3):
  331. async with _scheduler(farm, refused, scheduler=scheduler):
  332. await scheduler.check_queue()
  333. _expire_backoff(scheduler, farm.ids.refusing)
  334. async with _scheduler(farm, AsyncMock(return_value=True), scheduler=scheduler):
  335. await scheduler.check_queue()
  336. assert farm.ids.refusing not in scheduler._upload_refusals
  337. await _finish(farm, scheduler, first)
  338. # It breaks again later: back to the first window, not the fourth.
  339. await farm.add_item(printer_id=farm.ids.refusing)
  340. async with _scheduler(farm, refused, scheduler=scheduler, busy=set()):
  341. await scheduler.check_queue()
  342. assert _remaining(scheduler, farm.ids.refusing) == pytest.approx(UPLOAD_FAILURE_BACKOFF_SECONDS, abs=5)
  343. @pytest.mark.asyncio
  344. async def test_one_outage_sends_one_waiting_notification(self, farm):
  345. """Every retry clears the waiting reason and the next refusal sets it again.
  346. `hold_item` reads that as a new reason each time, and "Job Waiting" is
  347. on by default -- so without a guard a 40-hour outage notifies ~480 times.
  348. """
  349. await farm.add_item(printer_id=farm.ids.refusing)
  350. upload = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  351. scheduler = PrintScheduler()
  352. waiting = AsyncMock()
  353. for _ in range(4):
  354. # The pass that dispatches and is refused...
  355. async with _scheduler(farm, upload, scheduler=scheduler, waiting_notify=waiting):
  356. await scheduler.check_queue()
  357. # ...and the pass that holds the item while the printer waits.
  358. async with _scheduler(farm, upload, scheduler=scheduler, waiting_notify=waiting):
  359. await scheduler.check_queue()
  360. _expire_backoff(scheduler, farm.ids.refusing)
  361. assert waiting.await_count == 1
  362. assert "not accepting files" in waiting.await_args.kwargs["waiting_reason"]
  363. @pytest.mark.asyncio
  364. async def test_a_new_outage_after_a_recovery_notifies_again(self, farm):
  365. first = await farm.add_item(printer_id=farm.ids.refusing)
  366. refused = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  367. scheduler = PrintScheduler()
  368. waiting = AsyncMock()
  369. async def outage():
  370. async with _scheduler(farm, refused, scheduler=scheduler, waiting_notify=waiting):
  371. await scheduler.check_queue()
  372. async with _scheduler(farm, refused, scheduler=scheduler, waiting_notify=waiting):
  373. await scheduler.check_queue()
  374. _expire_backoff(scheduler, farm.ids.refusing)
  375. await outage()
  376. async with _scheduler(farm, AsyncMock(return_value=True), scheduler=scheduler, waiting_notify=waiting):
  377. await scheduler.check_queue()
  378. await _finish(farm, scheduler, first)
  379. await farm.add_item(printer_id=farm.ids.refusing)
  380. await outage()
  381. assert waiting.await_count == 2
  382. @pytest.mark.asyncio
  383. async def test_keep_warm_does_not_hold_a_bed_for_a_printer_in_backoff(self, farm):
  384. """There is no print coming to keep the bed warm for.
  385. The printer is in busy_printers and has a pending item, which is
  386. exactly what keep-warm looks for -- and each retry would restart its
  387. time cap, so the bed could stay hot for the whole outage.
  388. """
  389. await farm.add_item(printer_id=farm.ids.refusing)
  390. upload = _upload_refused_by(REFUSING_IP, HANDSHAKE)
  391. scheduler = PrintScheduler()
  392. async with _scheduler(farm, upload, scheduler=scheduler):
  393. await scheduler.check_queue()
  394. keep_warm = AsyncMock()
  395. async with _scheduler(farm, upload, scheduler=scheduler):
  396. with patch.object(scheduler, "_apply_keep_warm", keep_warm):
  397. await scheduler.check_queue()
  398. busy_seen = keep_warm.await_args.args[3]
  399. assert farm.ids.refusing not in busy_seen
  400. # ---------------------------------------------------------------------------
  401. # 452 is the card, not the network
  402. # ---------------------------------------------------------------------------
  403. class TestA452IsAStorageReply:
  404. def _upload_raising(self, error, tmp_path):
  405. local = tmp_path / "job.3mf"
  406. local.write_bytes(b"x" * 16)
  407. client = BambuFTPClient(REFUSING_IP, "12345678")
  408. client._ftp = MagicMock()
  409. client._ftp.transfercmd.side_effect = error
  410. assert client.upload_file(local, "/job.3mf") is False
  411. return client.last_failure
  412. def test_452_is_storage(self, tmp_path):
  413. """ftplib raises it as error_temp, but it says the card is full.
  414. Read as NETWORK it would be retried forever instead of failing with
  415. the advice to check the card.
  416. """
  417. import ftplib # nosec B402 -- tests construct real ftplib error types
  418. failure = self._upload_raising(ftplib.error_temp("452 Insufficient storage space."), tmp_path)
  419. assert failure.kind is FtpFailureKind.STORAGE
  420. assert failure.code == "452"
  421. def test_other_transient_replies_are_still_network(self, tmp_path):
  422. import ftplib # nosec B402 -- tests construct real ftplib error types
  423. failure = self._upload_raising(ftplib.error_temp("421 There are too many connections."), tmp_path)
  424. assert failure.kind is FtpFailureKind.NETWORK
  425. def test_a_socket_error_is_never_read_as_a_reply_code(self, tmp_path):
  426. failure = self._upload_raising(OSError("452 looks like a code but is not a reply"), tmp_path)
  427. assert failure.kind is FtpFailureKind.NETWORK