From a4eafabe2e7c05c6985e8113e3d8eccbb300ac51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ahlert?= Date: Tue, 19 May 2026 19:12:02 -0300 Subject: [PATCH] fix(gmail-oauth-draft): wrap api_get with HTTPError handler for clean failure surface `api_get` was the only `urllib.request.urlopen` call in the package without a `try/except urllib.error.HTTPError`. `api_post`, `mark_threads_read` (both calls), and the token refresh in `credentials.py` all wrap the call and raise `SystemExit` with a one-line `"Gmail API failed (): "` message. `api_get` is the path that `latest_reply_headers` uses, so any 401 / 403 / 404 from `GET /threads/{id}?format=full` surfaced as a raw Python traceback instead of the clean error the rest of the tool produces. That path is exercised on every `oauth-draft-create --thread-id ` invocation that does not pass `--no-reply-headers`. Wraps `api_get` with the same handler as `api_post` and adds a regression test mirroring `test_api_post_raises_on_http_error`. --- .../src/oauth_draft/create_draft.py | 7 ++++-- .../oauth-draft/tests/test_create_draft.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py b/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py index e24fcc6b..bfc07e93 100644 --- a/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py +++ b/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py @@ -61,8 +61,11 @@ def api_get(access_token: str, path: str) -> dict: f"{GMAIL_API}{path}", headers={"Authorization": f"Bearer {access_token}"}, ) - with urllib.request.urlopen(req, timeout=15) as r: - return json.loads(r.read()) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + raise SystemExit(f"Gmail API {path} failed ({e.code}): {e.read().decode(errors='replace')}") from e def api_post(access_token: str, path: str, payload: dict) -> dict: diff --git a/tools/gmail/oauth-draft/tests/test_create_draft.py b/tools/gmail/oauth-draft/tests/test_create_draft.py index d643962b..a50056e6 100644 --- a/tools/gmail/oauth-draft/tests/test_create_draft.py +++ b/tools/gmail/oauth-draft/tests/test_create_draft.py @@ -250,6 +250,29 @@ def test_api_post_parses_json_response(): assert json.loads(request.data) == {"message": {"raw": "X"}} +def test_api_get_raises_on_http_error(): + """``api_get`` must surface HTTP errors as a clean ``SystemExit``. + + Regression: ``api_get`` was the only ``urlopen`` call in the + package without a ``try/except HTTPError``, so a 401/403/404 from + ``threads.get`` (the path exercised on every ``oauth-draft-create + --thread-id `` invocation) produced a raw Python traceback + instead of a one-line error matching the rest of the tool. + """ + err = urllib.error.HTTPError( + url="https://x", + code=404, + msg="Not Found", + hdrs=None, # type: ignore[arg-type] + fp=io.BytesIO(b'{"error": "thread missing"}'), + ) + with patch("oauth_draft.create_draft.urllib.request.urlopen", side_effect=err): + with pytest.raises(SystemExit) as excinfo: + api_get("token", "/threads/missing-thread") + assert "failed (404)" in str(excinfo.value) + assert "thread missing" in str(excinfo.value) + + def test_api_post_raises_on_http_error(): err = urllib.error.HTTPError( url="https://x",