diff --git a/pyproject.toml b/pyproject.toml index 9578abb..a369a88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "khwan" -version = "0.4.1" +version = "0.4.2" description = "Khwan hosted client — the cognition layer (memory + identity + learning) for your own agent. Bring your own model." readme = "README.md" requires-python = ">=3.9" diff --git a/src/khwan/__init__.py b/src/khwan/__init__.py index edf52a4..174fb3f 100644 --- a/src/khwan/__init__.py +++ b/src/khwan/__init__.py @@ -133,14 +133,31 @@ def _retry_delay(attempt: int, retry_after: Optional[str]) -> float: return base * (0.75 + random.random() * 0.5) -def _error_message(status: int, text: str) -> str: - """A message that says what to DO, for the statuses with an obvious answer.""" - return { - 401: "unauthorized — bad or missing API key", +def _error_message(status: int, text: str, bearer: bool = False) -> str: + """What to DO about this status, followed by what the server actually said. + + The hint alone used to be the whole message, and the server's text was + dropped. That hides the only part that distinguishes causes: a 401 for a + credential that was rejected reads identically to a 401 for one that never + arrived, and the caller cannot tell which without guessing. + + The 401 hint also depends on WHICH credential this client carries. An + OAuth-authenticated caller has no API key to check, and being sent to look + for one is a wrong turn rather than a vague one. + """ + hint = { + 401: ("unauthorized — the OAuth token was rejected or missing; it may have " + "expired, in which case authorize again") + if bearer else + ("unauthorized — bad or missing API key"), 402: "payment required — add a payment method / upgrade your plan", 404: "not found — check the core in X-Khwan-Core exists", 429: "rate limited / over your plan's limit — retry later", - }.get(status, text[:300]) + }.get(status) + detail = (text or "").strip()[:300] + if hint and detail: + return f"{hint} ({detail})" + return hint or detail def _auth_headers(api_key: Optional[str], user_id: Optional[str], core: Optional[str], @@ -234,7 +251,7 @@ def _request(self, method: str, path: str, body: Optional[dict] = None) -> dict: continue if r.status_code // 100 != 2: - raise KhwanError(r.status_code, _error_message(r.status_code, r.text)) + raise KhwanError(r.status_code, _error_message(r.status_code, r.text, bool(self._bearer))) return r.json() if r.content else {} # ---- the memory loop: prepare → (your model) → record ---- @@ -535,7 +552,7 @@ async def _request(self, method: str, path: str, body: Optional[dict] = None) -> continue if r.status_code // 100 != 2: - raise KhwanError(r.status_code, _error_message(r.status_code, r.text)) + raise KhwanError(r.status_code, _error_message(r.status_code, r.text, bool(self._bearer))) return r.json() if r.content else {} # ---- the memory loop ---- diff --git a/test_bearer.py b/test_bearer.py index 5d153a2..c57e4c9 100644 --- a/test_bearer.py +++ b/test_bearer.py @@ -91,3 +91,45 @@ def test_async_client_refuses_both(): from khwan import AsyncKhwan with pytest.raises(ValueError, match="exactly one"): AsyncKhwan(api_key="kwk_live_x", bearer_token=JWT) + + +# ── what an error says ──────────────────────────────────────────────────────── +# The hint used to be the whole message and the server's text was discarded, +# which hides the only part that separates causes. + +from khwan import _error_message # noqa: E402 + + +def test_the_servers_own_words_survive(): + """`Invalid bearer token` and `Missing credentials` are different problems.""" + rejected = _error_message(401, "Invalid bearer token", True) + absent = _error_message(401, "Missing credentials (X-API-Key or Authorization: Bearer)", True) + assert "Invalid bearer token" in rejected + assert "Missing credentials" in absent + assert rejected != absent + + +def test_a_bearer_caller_is_not_sent_looking_for_an_api_key(): + """The wrong turn: an OAuth caller has no API key to check.""" + msg = _error_message(401, "Invalid bearer token", True) + assert "API key" not in msg + assert "authorize again" in msg + + +def test_an_api_key_caller_still_hears_about_the_key(): + msg = _error_message(401, "Invalid API key", False) + assert "API key" in msg + + +def test_other_statuses_keep_their_hint_and_gain_the_detail(): + msg = _error_message(402, "per-user memory: the free plan allows 3 sub-brain(s)") + assert "upgrade" in msg + assert "free plan allows 3" in msg + + +def test_an_unmapped_status_is_just_the_server_text(): + assert _error_message(500, "internal error") == "internal error" + + +def test_no_detail_leaves_the_hint_alone(): + assert _error_message(429, "") == "rate limited / over your plan's limit — retry later"