Jelajahi Sumber

fix(cloud): log expected preset misses at DEBUG, keep real faults at WARNING

Failed to get cloud preset ... 400 {"message":"missing"} is the expected
answer, not a fault: many official presets are only addressable with a
printer-variant suffix (GFSL05 exists solely as GFSL05_07 @BBL A1), and
personal P-prefixed presets belong to the account that sliced the file.
Phase 3 already resolves both from local presets, so the lookup miss is
routine -- and one WARNING per AMS tray per tooltip refresh teaches
operators to ignore the log.

BambuCloudError now carries the upstream status_code. The preset lookup
logs HTTP 400 at DEBUG; expired tokens, 5xx and transport failures stay
at WARNING.

Not fixed here: resolving the variant suffix. It selects a printer profile
and the response carries that profile's pressure_advance, so guessing a
suffix would report another printer's K value.
maziggy 1 bulan lalu
induk
melakukan
50c3e94d33

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 18 - 3
backend/app/api/routes/cloud.py

@@ -861,9 +861,24 @@ async def get_filament_info(
                         if not name:
                             still_unresolved.append(setting_id)
                     except Exception as e:
-                        logger.warning(
-                            f"Failed to get cloud preset {setting_id} "
-                            f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
+                        # A 400 here is the *expected* answer, not a fault, and Phase 3
+                        # exists to handle it (#2530). Two routine causes:
+                        #   * Many official presets are only addressable with a printer
+                        #     variant suffix — "GFSA00" resolves, "GFSL05" does not, only
+                        #     "GFSL05_07" (@BBL A1) does. The bare ID is all the AMS
+                        #     reports, so the lookup legitimately misses.
+                        #   * Personal presets ("P…") belong to the Bambu account that
+                        #     sliced the file; another account will never resolve them.
+                        # Logging those at WARNING on every AMS tooltip refresh trains
+                        # users to ignore the log. Anything else — expired token, 5xx,
+                        # a connection failure — stays at WARNING because it is a fault.
+                        expected_miss = isinstance(e, BambuCloudError) and e.status_code == 400
+                        logger.log(
+                            logging.DEBUG if expected_miss else logging.WARNING,
+                            "Failed to get cloud preset %s (API ID: %s): %s",
+                            setting_id,
+                            _filament_id_to_setting_id(setting_id),
+                            e,
                         )
                         still_unresolved.append(setting_id)
 

+ 14 - 3
backend/app/services/bambu_cloud.py

@@ -79,9 +79,17 @@ _SLICER_API_VERSION = "1.0.0.0"
 
 
 class BambuCloudError(Exception):
-    """Base exception for Bambu Cloud errors."""
+    """Base exception for Bambu Cloud errors.
 
-    pass
+    ``status_code`` carries the upstream HTTP status when the failure came from
+    a response rather than from the transport, so callers can tell an expected
+    "this preset isn't in the catalog" 400 apart from an expired token or a
+    cloud outage. It stays ``None`` for connection-level failures.
+    """
+
+    def __init__(self, message: str, *, status_code: int | None = None):
+        super().__init__(message)
+        self.status_code = status_code
 
 
 class BambuCloudAuthError(BambuCloudError):
@@ -408,7 +416,10 @@ class BambuCloudService:
 
             # Include body so a future contract change is self-diagnostic from logs.
             body = (response.text or "")[:200]
-            raise BambuCloudError(f"Failed to get setting detail: {response.status_code} {body}")
+            raise BambuCloudError(
+                f"Failed to get setting detail: {response.status_code} {body}",
+                status_code=response.status_code,
+            )
 
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")

+ 76 - 0
backend/tests/integration/test_cloud_token_auth_migration.py

@@ -12,11 +12,14 @@ These tests pin the hand-off in both directions, and — just as importantly —
 pin the two cases where Bambuddy must refuse to guess who owns a credential.
 """
 
+import logging
+
 import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes import cloud as cloud_routes
 from backend.app.api.routes.cloud import (
     CLOUD_EMAIL_KEY,
     CLOUD_REGION_KEY,
@@ -26,6 +29,7 @@ from backend.app.api.routes.cloud import (
 from backend.app.core.auth import get_password_hash
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
+from backend.app.services.bambu_cloud import BambuCloudError
 
 
 async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
@@ -199,3 +203,75 @@ async def test_disable_auth_with_no_cloud_token_is_a_noop(async_client: AsyncCli
     resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
     assert resp.status_code == 200, resp.text
     assert await _global_rows(db_session) == {}
+
+
+# ---------------------------------------------------------------------------
+# Log-level classification for cloud preset misses (#2530)
+# ---------------------------------------------------------------------------
+
+
+def test_bambu_cloud_error_carries_status_code():
+    """The 400-vs-fault distinction depends on this attribute existing."""
+    from backend.app.services.bambu_cloud import BambuCloudError
+
+    assert BambuCloudError("boom").status_code is None
+    assert BambuCloudError("boom", status_code=400).status_code == 400
+    # Every pre-existing raise site passes a bare message; must not break.
+    assert str(BambuCloudError("Request failed: timeout")) == "Request failed: timeout"
+
+
+class _StubCloud:
+    """Minimal stand-in for BambuCloudService that fails every preset lookup."""
+
+    def __init__(self, error: Exception):
+        self._error = error
+        self.is_authenticated = True
+
+    async def get_setting_detail(self, setting_id: str) -> dict:
+        raise self._error
+
+    async def close(self) -> None:
+        return None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("error", "expected_level"),
+    [
+        (BambuCloudError("missing", status_code=400), logging.DEBUG),
+        (BambuCloudError("unauthorized", status_code=401), logging.WARNING),
+        (BambuCloudError("bad gateway", status_code=502), logging.WARNING),
+        (BambuCloudError("Request failed: timeout"), logging.WARNING),
+    ],
+    ids=["expected-400-miss", "expired-token", "cloud-outage", "transport-failure"],
+)
+async def test_preset_miss_logs_at_debug_but_faults_stay_at_warning(
+    monkeypatch, caplog, db_session: AsyncSession, error: Exception, expected_level: int
+):
+    """A preset the catalog doesn't carry is routine; an expired token is a fault.
+
+    Drives the real ``get_filament_info`` route so the classification is exercised
+    where it lives, not re-derived in the test.
+    """
+    monkeypatch.setattr(cloud_routes, "build_authenticated_cloud", _stub_builder(error))
+    # Phase 1 would otherwise short-circuit the cloud call on a warm cache.
+    monkeypatch.setattr(cloud_routes, "_filament_cache", {})
+
+    with caplog.at_level(logging.DEBUG, logger="backend.app.api.routes.cloud"):
+        result = await cloud_routes.get_filament_info(setting_ids=["GFL05"], db=db_session, current_user=None)
+
+    records = [r for r in caplog.records if "Failed to get cloud preset" in r.getMessage()]
+    assert len(records) == 1, "the miss must be logged exactly once"
+    assert records[0].levelno == expected_level
+    assert "GFL05" in records[0].getMessage()
+    assert "GFSL05" in records[0].getMessage(), "the translated API ID stays in the message"
+
+    # Whatever the level, the endpoint still answers and falls through to Phase 3.
+    assert isinstance(result, dict)
+
+
+def _stub_builder(error: Exception):
+    async def _build(db, user):
+        return _StubCloud(error)
+
+    return _build

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini