diff --git a/main.py b/main.py index 627c74a..baf8c53 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import logging import os +import threading from fastapi import FastAPI, HTTPException, Query import requests import humanize @@ -37,31 +38,47 @@ app = FastAPI() AUTH_TOKEN = None +AUTH_TOKEN_LOCK = threading.Lock() def get_greader_token(): global AUTH_TOKEN - if AUTH_TOKEN: - return AUTH_TOKEN - login_url = f"{FRESHRSS_HOST}/api/greader.php/accounts/ClientLogin" - payload = { - "Email": FRESHRSS_USERNAME, - "Passwd": FRESHRSS_PASSWORD, - } - try: - res = requests.post(login_url, data=payload, timeout=10) - except requests.RequestException as exc: - logging.warning("FreshRSS login request failed: %s", exc) - raise HTTPException(status_code=502, detail="FreshRSS login request failed") from exc - if res.status_code != 200: - logging.warning("FreshRSS login failed (status %d): %s", res.status_code, res.text) - raise HTTPException(status_code=502, detail=f"FreshRSS login failed with status {res.status_code}") - # Find and extract 'Auth=' line - for line in res.text.splitlines(): - if line.startswith("Auth="): - AUTH_TOKEN = line.replace("Auth=", "").strip() + with AUTH_TOKEN_LOCK: + if AUTH_TOKEN: return AUTH_TOKEN - raise HTTPException(status_code=502, detail="Auth token not found in FreshRSS response") + login_url = f"{FRESHRSS_HOST}/api/greader.php/accounts/ClientLogin" + payload = { + "Email": FRESHRSS_USERNAME, + "Passwd": FRESHRSS_PASSWORD, + } + try: + res = requests.post(login_url, data=payload, timeout=10) + except requests.RequestException as exc: + logging.warning("FreshRSS login request failed: %s", exc) + raise HTTPException(status_code=502, detail="FreshRSS login request failed") from exc + if res.status_code != 200: + logging.warning("FreshRSS login failed (status %d): %s", res.status_code, res.text) + raise HTTPException(status_code=502, detail=f"FreshRSS login failed with status {res.status_code}") + # Find and extract 'Auth=' line + for line in res.text.splitlines(): + if line.startswith("Auth="): + AUTH_TOKEN = line.replace("Auth=", "").strip() + return AUTH_TOKEN + raise HTTPException(status_code=502, detail="Auth token not found in FreshRSS response") + + +def request_unread(token, n, category): + """Construct and send one upstream unread request.""" + headers = {"Authorization": f"GoogleLogin auth={token}"} + params = { + "xt": "user/-/state/com.google/read", + "output": "json", + "n": n, + } + category_label = category if isinstance(category, str) and category else None + stream_id = f"user/-/label/{category_label}" if category_label else "user/-/state/com.google/reading-list" + url = f"{FRESHRSS_HOST}/api/greader.php/reader/api/0/stream/contents/{stream_id}" + return requests.get(url, headers=headers, params=params, timeout=10) @app.get("/health") @@ -75,18 +92,15 @@ def freshrss_unread( category: str | None = Query(default=None), ): token = get_greader_token() - headers = {"Authorization": f"GoogleLogin auth={token}"} - params = { - "xt": "user/-/state/com.google/read", - "output": "json", - "n": n, - } - category_label = category if isinstance(category, str) and category else None - stream_id = f"user/-/label/{category_label}" if category_label else "user/-/state/com.google/reading-list" - # Using the same host as before but with the right endpoint - url = f"{FRESHRSS_HOST}/api/greader.php/reader/api/0/stream/contents/{stream_id}" try: - r = requests.get(url, headers=headers, params=params, timeout=10) + r = request_unread(token, n, category) + if r.status_code in (401, 403): + global AUTH_TOKEN + with AUTH_TOKEN_LOCK: + if AUTH_TOKEN == token: + AUTH_TOKEN = None + token = get_greader_token() + r = request_unread(token, n, category) r.raise_for_status() raw = r.json() except requests.RequestException as exc: diff --git a/tests/test_main.py b/tests/test_main.py index 8e624e9..e2a8393 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,8 @@ import importlib import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -31,6 +34,8 @@ def json(self): def raise_for_status(self): if self._raise_error: raise self._raise_error + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code} upstream error") def import_app(monkeypatch, env=None): @@ -204,3 +209,79 @@ def test_freshrss_unread_wraps_upstream_http_errors(monkeypatch): assert excinfo.value.status_code == 502 assert excinfo.value.detail == "FreshRSS unread request failed" + + +def test_freshrss_unread_reauthenticates_and_retries_once(monkeypatch): + main = import_app(monkeypatch) + main.AUTH_TOKEN = "expired-token" + login_calls = [] + get_calls = [] + + def fake_post(*args, **kwargs): + login_calls.append((args, kwargs)) + return FakeResponse(text="Auth=fresh-token") + + def fake_get(url, headers, params, timeout): + get_calls.append(headers["Authorization"]) + if len(get_calls) == 1: + return FakeResponse(status_code=401) + return FakeResponse(payload={"items": []}) + + monkeypatch.setattr(main.requests, "post", fake_post) + monkeypatch.setattr(main.requests, "get", fake_get) + + assert main.freshrss_unread() == [] + assert get_calls == [ + "GoogleLogin auth=expired-token", + "GoogleLogin auth=fresh-token", + ] + assert len(login_calls) == 1 + assert main.AUTH_TOKEN == "fresh-token" + + +def test_freshrss_unread_fails_after_single_reauthentication_retry(monkeypatch): + main = import_app(monkeypatch) + main.AUTH_TOKEN = "expired-token" + get_calls = [] + monkeypatch.setattr(main.requests, "post", lambda *args, **kwargs: FakeResponse(text="Auth=fresh-token")) + + def fake_get(*args, **kwargs): + get_calls.append(kwargs["headers"]["Authorization"]) + return FakeResponse(status_code=403) + + monkeypatch.setattr(main.requests, "get", fake_get) + + with pytest.raises(HTTPException) as excinfo: + main.freshrss_unread() + + assert excinfo.value.status_code == 502 + assert excinfo.value.detail == "FreshRSS unread request failed" + assert get_calls == [ + "GoogleLogin auth=expired-token", + "GoogleLogin auth=fresh-token", + ] + + +def test_get_greader_token_is_concurrent_safe(monkeypatch): + main = import_app(monkeypatch) + login_calls = 0 + calls_lock = threading.Lock() + workers_ready = threading.Barrier(5) + + def fake_post(*args, **kwargs): + nonlocal login_calls + with calls_lock: + login_calls += 1 + time.sleep(0.05) + return FakeResponse(text="Auth=shared-token") + + def acquire_token(): + workers_ready.wait() + return main.get_greader_token() + + monkeypatch.setattr(main.requests, "post", fake_post) + with ThreadPoolExecutor(max_workers=5) as executor: + tokens = list(executor.map(lambda _: acquire_token(), range(5))) + + assert tokens == ["shared-token"] * 5 + assert login_calls == 1