fix(M5): corrections d'audit — transport HTTP, statuts d'erreur, cache atomique

Corrige les 5 points de l'audit FIXME_M5 :

1. (Bloquant) Sépare transport HTTP et parsing : utilise requests.get()
   avec timeout explicite et en-têtes conditionnels, puis transmet le
   contenu à feedparser.parse() — supprime le paramètre inexistant
   request_timeout qui faisait échouer toute récupération réelle.
2. Rejette les statuts HTTP 4xx/5xx via raise_for_status() avant le
   parsing.
3. Préserve les validateurs de cache (etag, last_modified) d'entrée sur
   les chemins d'échec (exception, bozo) au lieu de les écraser à None.
4. Sauvegarde atomique de BlogRSSState : écrit dans un .tmp puis
   Path.replace() pour éviter la corruption sur interruption.
5. Déduplication normale silencieuse : les GUID déjà connus sont
   ignorés sans warning ; seuls les doublons intra-flux génèrent un
   avertissement.

Tests : 49 tests (32 client + 17 state) dont 11 nouveaux couvrant
transport HTTP réel, statuts 401/404/500, préservation des validateurs,
en-têtes conditionnels, doublons intra-flux et sauvegarde atomique.
Guide : §5 bis.7.1 aligné avec le nouveau pattern transport/parsing.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 20:58:04 +02:00
parent 6d1a7a649f
commit bfae1ca87f
6 changed files with 753 additions and 98 deletions

View File

@@ -850,6 +850,7 @@ from datetime import UTC, datetime
from html import unescape
import feedparser
import requests
from bs4 import BeautifulSoup
from ..models.blog import BlogArticle
@@ -891,16 +892,17 @@ class BlogRSSClient:
indicateur ``304 Not Modified``.
"""
try:
# Récupération du flux avec requête conditionnelle (ETag / Last-Modified)
feed = feedparser.parse(
self.rss_url,
etag=etag,
modified=last_modified,
request_timeout=self.timeout,
)
# Transport HTTP séparé du parsing
headers: dict[str, str] = {"user-agent": "pronote-sync"}
if etag is not None:
headers["If-None-Match"] = etag
if last_modified is not None:
headers["If-Modified-Since"] = last_modified
# Réponse 304 Not Modified : rien n'a changé, on restitue les en-têtes mémorisés
if getattr(feed, "status", None) == 304:
response = requests.get(self.rss_url, headers=headers, timeout=self.timeout)
# 304 Not Modified : pas de nouveaux articles
if response.status_code == 304:
return BlogRSSFetchResult(
articles=(),
etag=etag,
@@ -908,10 +910,18 @@ class BlogRSSClient:
not_modified=True,
)
response_etag: str | None = getattr(feed, "etag", None)
response_last_modified: str | None = getattr(feed, "modified", None)
# Rejeter les statuts d'erreur (4xx/5xx)
response.raise_for_status()
# Flux invalide (erreur HTTP, XML malformé, etc.) : résultat vide, sans erreur
# Extraire les en-têtes de cache de la réponse (ETag / Last-Modified)
response_etag: str | None = response.headers.get("ETag")
response_last_modified: str | None = response.headers.get("Last-Modified")
# Parsing du contenu reçu (pas de l'URL)
feed = feedparser.parse(response.content)
# Flux invalide (XML malformé, etc.) : résultat vide, sans erreur ;
# les en-têtes de cache d'entrée sont conservés tels quels
if getattr(feed, "bozo", None):
logger.warning(
"Flux RSS du blog invalide, ignoré : %s",
@@ -919,8 +929,8 @@ class BlogRSSClient:
)
return BlogRSSFetchResult(
articles=(),
etag=response_etag,
last_modified=response_last_modified,
etag=etag,
last_modified=last_modified,
not_modified=False,
)
@@ -988,7 +998,12 @@ class BlogRSSClient:
except Exception as e:
safe_url = redact_url(self.rss_url)
logger.error(f"Échec de la récupération du flux RSS {safe_url}: {e}")
return BlogRSSFetchResult(articles=(), not_modified=False)
return BlogRSSFetchResult(
articles=(),
etag=etag,
last_modified=last_modified,
not_modified=False,
)
@staticmethod
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None: