[ka6] fix connecteur talan: relance des 5xx passagers dans le GET résilient — le statut stale vient d'un 502 Bad Gateway unique de api.smartrecruiters.com (page 2, offset=100) au sync de 03:13 le 2026-09-16, qui avortait tout le sync : contrairement au POST (relances 5xx ajoutées après l'incident jci/Workday du 2026-08-27), le GET résilient de base.py ne retentait pas les 5xx transitoires hors statuts anti-bot (502 absent de BLOCK_STATUS → pas d'escalade, pas de relance, raise immédiat). L'API est saine (vérifié live : 200 sur offset=0 et 100, totalFound=466, filtre QC → ~20) — hoquet serveur ponctuel. Correctif générique dans _ka_resilient_get : 3 tentatives directes avec backoff sur 5xx non-bloquant (même logique que _ka_resilient_post), escalade proxy inchangée pour 403/429/503/52x, 4xx remontés sans relance. Test unitaire ajouté (502×2 puis 200 → survit en 3 appels ; 502 persistant → 3 tentatives puis HTTPError ; 404 → aucune relance). Sync réel rejoué : found=20 (médiane exacte), ok. 106 tests pytest verts. pm2 restart job-ka-sync OK, site 200.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 changed files +77 −16
modified
jobka/connectors/base.py
+32 −16
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/base.py |
| 6 | 6 | # Rôle : Classe de base des connecteurs + backends de fetch (requests |
| 7 | 7 | # direct, Scrapfly pour les pages carrières JS / derrière anti-bot) |
| 8 | −# Créé : 2026-08-17 Modifié : 2026-09-09 | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-09-16 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | from __future__ import annotations |
| 11 | 11 | |
@@ -204,25 +204,41 @@ if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False): | ||
| 204 | 204 | return url |
| 205 | 205 | |
| 206 | 206 | def _ka_resilient_get(self, url, **kw): |
| 207 | + """GET avec escalade anti-bot + relances sur 5xx passager. | |
| 208 | + | |
| 209 | + Un 5xx transitoire hors statuts anti-bot (ex. 502 unique de | |
| 210 | + api.smartrecruiters.com sur la page 2 de talan le 2026-09-16, retour | |
| 211 | + à la normale immédiat) ne doit pas avorter tout le sync de la source | |
| 212 | + — même logique que _ka_resilient_post. Les statuts anti-bot | |
| 213 | + (403/429/503/52x) partent en escalade proxy comme avant, les 4xx | |
| 214 | + remontent tels quels (rejouer à l'identique ne les débloque pas).""" | |
| 207 | 215 | timeout = getattr(self, "timeout", 30) |
| 208 | 216 | headers = kw.get("headers") |
| 209 | − try: | |
| 210 | − return _ka_orig_get(self, url, **kw) | |
| 211 | − except _ka_requests.HTTPError as exc: | |
| 212 | − r = getattr(exc, "response", None) | |
| 213 | − if r is not None and _kar.is_blocked(r): | |
| 214 | − target = getattr(r, "url", None) or _ka_full_url(url, kw) | |
| 215 | − better = _kar.escalate_if_blocked( | |
| 216 | − r, target, timeout=timeout, headers=headers) | |
| 217 | + last_exc = None | |
| 218 | + for attempt in range(3): | |
| 219 | + try: | |
| 220 | + return _ka_orig_get(self, url, **kw) | |
| 221 | + except _ka_requests.HTTPError as exc: | |
| 222 | + r = getattr(exc, "response", None) | |
| 223 | + if r is not None and _kar.is_blocked(r): | |
| 224 | + target = getattr(r, "url", None) or _ka_full_url(url, kw) | |
| 225 | + better = _kar.escalate_if_blocked( | |
| 226 | + r, target, timeout=timeout, headers=headers) | |
| 227 | + if better is not None and getattr(better, "status_code", 0) == 200: | |
| 228 | + return better | |
| 229 | + raise | |
| 230 | + if r is None or r.status_code < 500: | |
| 231 | + raise | |
| 232 | + last_exc = exc | |
| 233 | + except (_ka_requests.ConnectionError, _ka_requests.Timeout): | |
| 234 | + better = _kar.escalate(_ka_full_url(url, kw), | |
| 235 | + timeout=timeout, headers=headers) | |
| 217 | 236 | if better is not None and getattr(better, "status_code", 0) == 200: |
| 218 | 237 | return better |
| 219 | − raise | |
| 220 | − except (_ka_requests.ConnectionError, _ka_requests.Timeout): | |
| 221 | − better = _kar.escalate(_ka_full_url(url, kw), | |
| 222 | − timeout=timeout, headers=headers) | |
| 223 | − if better is not None and getattr(better, "status_code", 0) == 200: | |
| 224 | − return better | |
| 225 | − raise | |
| 238 | + raise | |
| 239 | + if attempt < 2: | |
| 240 | + time.sleep(5 * (attempt + 1)) | |
| 241 | + raise last_exc | |
| 226 | 242 | |
| 227 | 243 | def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw): |
| 228 | 244 | """Fetch anti-bot explicite : force la chaîne de secours au besoin. |
modified
tests/test_connectors.py
+45 −0
@@ -117,6 +117,51 @@ def test_njoyn_listing_retries_network_errors(monkeypatch): | ||
| 117 | 117 | conn2._fetch_listing() |
| 118 | 118 | |
| 119 | 119 | |
| 120 | +def test_get_retries_transient_5xx(monkeypatch): | |
| 121 | + """Un 5xx passager hors statuts anti-bot (502 unique de | |
| 122 | + api.smartrecruiters.com sur la page 2 de talan le 2026-09-16, retour à la | |
| 123 | + normale immédiat) doit être relancé en direct par le GET résilient au lieu | |
| 124 | + d'avorter tout le sync — même logique que le POST (jci 2026-08-27). Après | |
| 125 | + 3 échecs l'exception remonte ; un 4xx remonte immédiatement sans relance.""" | |
| 126 | + import requests | |
| 127 | + | |
| 128 | + from jobka.connectors.base import BaseConnector | |
| 129 | + | |
| 130 | + monkeypatch.setattr("jobka.connectors.base.time.sleep", lambda s: None) | |
| 131 | + | |
| 132 | + def make_conn(statuses): | |
| 133 | + conn = BaseConnector() | |
| 134 | + calls = {"n": 0} | |
| 135 | + | |
| 136 | + def fake_get(url, timeout=None, **kw): | |
| 137 | + resp = requests.Response() | |
| 138 | + resp.status_code = statuses[min(calls["n"], len(statuses) - 1)] | |
| 139 | + resp.url = url | |
| 140 | + resp._content = b"ok" | |
| 141 | + calls["n"] += 1 | |
| 142 | + return resp | |
| 143 | + | |
| 144 | + monkeypatch.setattr(conn.session, "get", fake_get) | |
| 145 | + return conn, calls | |
| 146 | + | |
| 147 | + # 502 ×2 puis 200 : le sync survit au hoquet serveur | |
| 148 | + conn, calls = make_conn([502, 502, 200]) | |
| 149 | + resp = conn.get("https://api.example.com/postings") | |
| 150 | + assert resp.status_code == 200 and calls["n"] == 3 | |
| 151 | + | |
| 152 | + # 502 persistant : 3 tentatives puis l'erreur remonte | |
| 153 | + conn2, calls2 = make_conn([502]) | |
| 154 | + with pytest.raises(requests.HTTPError): | |
| 155 | + conn2.get("https://api.example.com/postings") | |
| 156 | + assert calls2["n"] == 3 | |
| 157 | + | |
| 158 | + # 404 (erreur métier) : aucune relance | |
| 159 | + conn3, calls3 = make_conn([404]) | |
| 160 | + with pytest.raises(requests.HTTPError): | |
| 161 | + conn3.get("https://api.example.com/postings") | |
| 162 | + assert calls3["n"] == 1 | |
| 163 | + | |
| 164 | + | |
| 120 | 165 | def test_successfactors_sitemap_guard(monkeypatch): |
| 121 | 166 | """Une réponse 200 sans racine <rss>/<urlset> (maintenance, anti-bot, |
| 122 | 167 | corps vide — vu le 2026-09-13 06:14 sur alstom : found=0 unique contre |
| 123 | 168 | |