monitoring: sources vides légitimes ≠ pannes + stale seulement si la dernière synchro observée est malsaine
Revue du parc 2026-08-23 : les « brisés » niddamour, courtemanche, place_florimay, atlas_immo étaient des sources sans inventaire en ligne (vérifiées à la main) qui synchronisaient ok à 0 résultat. - un 0 résultat n est suspect que si la médiane historique de la source est ≥ 1 ; sinon c est un vide légitime (streak remis à zéro) - une source dont la dernière synchro observée est saine n est plus marquée stale même si son dernier succès avec résultats est ancien - tests mis à jour + 2 nouveaux cas (53 verts) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 changed files +51 −4
modified
src/monitoring/connector_health.py
+21 −2
@@ -148,9 +148,17 @@ def assess_source( | ||
| 148 | 148 | new_entries = [ |
| 149 | 149 | e for e in entries if prev_checked_ts is None or e.ts > prev_checked_ts |
| 150 | 150 | ] |
| 151 | + # Un « 0 résultat » n'est suspect que si la source a normalement des | |
| 152 | + # résultats (médiane historique ≥ 1). Une source vide légitimement | |
| 153 | + # (agence sans inventaire en ligne, employeur sans poste ouvert…) qui | |
| 154 | + # synchronise ok à 0 n'est PAS en panne — revue du 2026-08-23 : les | |
| 155 | + # « brisés » niddamour, courtemanche, place_florimay, atlas_immo étaient | |
| 156 | + # tous des vides légitimes vérifiés à la main. | |
| 157 | + legit_empty_history = (prev.median_found or 0) < 1 | |
| 151 | 158 | consecutive_failures = prev.consecutive_failures |
| 152 | 159 | for entry in reversed(new_entries): |
| 153 | − if not entry.ok or entry.found <= 0: | |
| 160 | + suspicious_zero = entry.ok and entry.found <= 0 and not legit_empty_history | |
| 161 | + if not entry.ok or suspicious_zero: | |
| 154 | 162 | consecutive_failures += 1 |
| 155 | 163 | else: |
| 156 | 164 | consecutive_failures = 0 |
@@ -175,6 +183,13 @@ def assess_source( | ||
| 175 | 183 | |
| 176 | 184 | last_message = next((e.message for e in entries if e.message), "") |
| 177 | 185 | |
| 186 | + # La dernière synchro observée est-elle saine ? (ok avec des résultats, | |
| 187 | + # ou ok à 0 pour une source vide légitimement) — si oui, la source n'est | |
| 188 | + # pas « stale » même si son dernier succès AVEC résultats est ancien. | |
| 189 | + latest_healthy = bool(entries) and entries[0].ok and ( | |
| 190 | + entries[0].found > 0 or legit_empty_history | |
| 191 | + ) | |
| 192 | + | |
| 178 | 193 | if consecutive_failures >= BROKEN_THRESHOLD: |
| 179 | 194 | message = ( |
| 180 | 195 | f"{consecutive_failures} synchros consécutives en échec ou à 0 résultat" |
@@ -182,7 +197,11 @@ def assess_source( | ||
| 182 | 197 | if last_message and last_message != "ok": |
| 183 | 198 | message += f" (dernier message : {last_message})" |
| 184 | 199 | status = STATUS_BROKEN |
| 185 | − elif last_success is not None and (now - last_success.timestamp()) > stale_after_seconds: | |
| 200 | + elif ( | |
| 201 | + not latest_healthy | |
| 202 | + and last_success is not None | |
| 203 | + and (now - last_success.timestamp()) > stale_after_seconds | |
| 204 | + ): | |
| 186 | 205 | hours = (now - last_success.timestamp()) / 3600 |
| 187 | 206 | message = ( |
| 188 | 207 | f"aucun sync réussi depuis {hours:.1f} h " |
modified
tests/test_connector_health.py
+30 −2
@@ -69,13 +69,41 @@ def test_assess_broken_apres_trois_echecs(): | ||
| 69 | 69 | |
| 70 | 70 | |
| 71 | 71 | def test_assess_broken_apres_trois_zero_resultats(): |
| 72 | − # Syncs "réussis" mais vides ×3 → broken. | |
| 72 | + # Syncs "réussis" mais vides ×3 ALORS QUE la source a normalement des | |
| 73 | + # résultats (médiane historique ≥ 1) → broken. | |
| 73 | 74 | entries = [_entry(h, found=0, ok=True) for h in (0.5, 1.5, 2.5)] |
| 74 | − result = assess_source(entries, SourceState(), NOW, stale_after_seconds=100 * HOUR) | |
| 75 | + prev = SourceState(median_found=40.0) | |
| 76 | + result = assess_source(entries, prev, NOW, stale_after_seconds=100 * HOUR) | |
| 75 | 77 | assert result.status == "broken" |
| 76 | 78 | assert result.consecutive_failures == 3 |
| 77 | 79 | |
| 78 | 80 | |
| 81 | +def test_assess_zero_legitime_pas_broken(): | |
| 82 | + # Source vide légitimement (médiane historique < 1) : des syncs ok à | |
| 83 | + # 0 résultat ne sont PAS des pannes (revue 2026-08-23 : niddamour, | |
| 84 | + # courtemanche, place_florimay, atlas_immo — vérifiés à la main). | |
| 85 | + entries = [_entry(h, found=0, ok=True) for h in (0.5, 1.5, 2.5)] | |
| 86 | + result = assess_source( | |
| 87 | + entries, SourceState(median_found=0.0), NOW, stale_after_seconds=100 * HOUR | |
| 88 | + ) | |
| 89 | + assert result.status != "broken" | |
| 90 | + assert result.consecutive_failures == 0 | |
| 91 | + | |
| 92 | + | |
| 93 | +def test_assess_zero_legitime_pas_stale(): | |
| 94 | + # Une source vide légitime qui synchronise ok n'est pas « stale » même | |
| 95 | + # si son dernier succès AVEC résultats est ancien. | |
| 96 | + prev = SourceState( | |
| 97 | + median_found=0.0, | |
| 98 | + last_success=datetime.datetime.fromtimestamp( | |
| 99 | + NOW - 500 * HOUR, tz=datetime.UTC | |
| 100 | + ), | |
| 101 | + ) | |
| 102 | + entries = [_entry(0.5, found=0, ok=True)] | |
| 103 | + result = assess_source(entries, prev, NOW, stale_after_seconds=100 * HOUR) | |
| 104 | + assert result.status not in ("stale", "broken") | |
| 105 | + | |
| 106 | + | |
| 79 | 107 | def test_assess_streak_cumule_avec_etat_precedent(): |
| 80 | 108 | prev = SourceState( |
| 81 | 109 | status="degraded", |
| 82 | 110 | |