Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 45 additions & 31 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import threading
from fastapi import FastAPI, HTTPException, Query
import requests
import humanize
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Comment on lines +103 to 104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate a token rejected by the retry

When the reauthenticated request also returns 401 or 403, this path calls raise_for_status() without clearing the newly cached token. Every subsequent client request therefore first sends a guaranteed-to-fail unread request with that known-rejected token before logging in and retrying, adding an avoidable upstream call and up to a timeout's worth of latency during persistent authentication or permission failures. The retry token should be invalidated on these statuses even though no further retry is attempted in the current request.

Useful? React with 👍 / 👎.

raw = r.json()
except requests.RequestException as exc:
Expand Down
81 changes: 81 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import importlib
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Loading