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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
RIPESTAT_BASE_URL=https://stat.ripe.net/data
ROUTEFORGE_DEMO_MODE=false
VITE_API_URL=http://localhost:8000

RIPESTAT_TIMEOUT_SECONDS=10
RIPESTAT_MAX_RETRIES=1
RIPESTAT_RETRY_BACKOFF_SECONDS=0.5
RIPESTAT_USE_STALE_CACHE_ON_ERROR=true
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

RouteForge is a read-only routing preflight and explainability tool for BGP, RPKI, Registry/IRR and Routing Visibility checks.

Current user-facing version: **v0.4.1-alpha**.
Current user-facing version: **v0.4.2-alpha**.

<!-- Screenshot gallery placeholder:
- docs/screenshots/dashboard.png
Expand Down Expand Up @@ -32,7 +32,7 @@ Routing changes often require fast but traceable checks across multiple external

## Current Alpha Status

RouteForge is a **functional alpha** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.4.1-alpha**.
RouteForge is a **functional alpha** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.4.2-alpha**.

## Quickstart with Docker Compose

Expand Down Expand Up @@ -96,6 +96,20 @@ Preflight decision model:

## Cache and Freshness


## Retry and Resilience

RouteForge retries temporary RIPEstat failures with configurable timeout/retry settings and exposes attempt/retry diagnostics in Data Source Diagnostics.

When stale-cache fallback is enabled, RouteForge can use stale cached data if the live request fails, and explicitly marks this in diagnostics and reports.

Configuration:
- `RIPESTAT_TIMEOUT_SECONDS`
- `RIPESTAT_MAX_RETRIES`
- `RIPESTAT_RETRY_BACKOFF_SECONDS`
- `RIPESTAT_USE_STALE_CACHE_ON_ERROR`


RouteForge shows per-source diagnostics indicating whether data was fetched live or served from cache. Cache Age, TTL and Freshness help operators assess how current a result is.

Freshness values:
Expand Down
13 changes: 13 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Release Notes

## v0.4.2-alpha

### Highlights

- Retry & Resilience
- Retry diagnostics
- Attempts/retry count visible
- Rate-limit handling
- Optional stale cache fallback
- UI/report warning when fallback data is used

---

## v0.4.1-alpha

### Highlights
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.4.1-alpha',
'version': 'v0.4.2-alpha',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
4 changes: 4 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ class Settings(BaseSettings):
database_url: str = "sqlite:///./routeforge.db"
ripestat_base_url: str = "https://stat.ripe.net/data"
http_timeout_seconds: int = 10
ripestat_timeout_seconds: float = 10
ripestat_max_retries: int = 1
ripestat_retry_backoff_seconds: float = 0.5
ripestat_use_stale_cache_on_error: bool = True
cache_ttl_seconds: int = Field(default=900, validation_alias="RIPESTAT_CACHE_TTL_SECONDS")
cors_origins: str = "http://192.168.58.167:3000,http://127.0.0.1:3000"
demo_mode: bool = Field(default=False, validation_alias="ROUTEFORGE_DEMO_MODE")
Expand Down
10 changes: 10 additions & 0 deletions backend/app/core/source_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ def make_source_diagnostic(
http_status: int | None = None,
error_type: str | None = None,
details: dict[str, Any] | None = None,
retry_count: int | None = None,
attempts: int | None = None,
fallback_used: bool | None = None,
fallback_reason: str | None = None,
stale_cache_used: bool | None = None,
) -> dict[str, Any]:
return {
"name": name,
Expand All @@ -88,4 +93,9 @@ def make_source_diagnostic(
"http_status": http_status,
"error_type": error_type,
"details": details or {},
"retry_count": retry_count,
"attempts": attempts,
"fallback_used": fallback_used,
"fallback_reason": fallback_reason,
"stale_cache_used": stale_cache_used,
}
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.config import settings
from app.database import Base, engine

app = FastAPI(title="RouteForge", version="0.4.1")
app = FastAPI(title="RouteForge", version="0.4.2")

app.add_middleware(
CORSMiddleware,
Expand Down
266 changes: 127 additions & 139 deletions backend/app/services/ripe_stat_client.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/app/templates/report.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
</ul>

<h2>Data Source Diagnostics</h2>
{% if report.details and report.details.source_diagnostics %}<table border="1" cellpadding="6" cellspacing="0"><tr><th>Quelle</th><th>Endpoint</th><th>Status</th><th>Dauer</th><th>Freshness</th><th>Cache</th><th>Cache Age</th><th>TTL</th><th>Message</th></tr>{% for d in report.details.source_diagnostics %}<tr><td>{{ d.name or '-' }}</td><td>{{ d.endpoint or '-' }}</td><td>{{ d.status or '-' }}</td><td>{{ d.duration_ms if d.duration_ms is not none else '-' }}</td><td>{{ d.freshness or 'UNKNOWN' }}</td><td>{{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }}</td><td>{{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }}</td><td>{{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }}</td><td>{{ d.message or '-' }}{% if d.details %}<details><summary>Details</summary><pre>{{ d.details | tojson(indent=2) }}</pre></details>{% endif %}</td></tr>{% endfor %}</table>{% else %}<p>No source diagnostics available.</p>{% endif %}
{% if report.details and report.details.source_diagnostics %}<table border="1" cellpadding="6" cellspacing="0"><tr><th>Quelle</th><th>Endpoint</th><th>Status</th><th>Freshness</th><th>Attempts</th><th>Retries</th><th>Fallback</th><th>Stale Cache</th><th>Dauer</th><th>Cache</th><th>Cache Age</th><th>TTL</th><th>Message</th></tr>{% for d in report.details.source_diagnostics %}<tr><td>{{ d.name or '-' }}</td><td>{{ d.endpoint or '-' }}</td><td>{{ d.status or '-' }}</td><td>{{ d.freshness or 'UNKNOWN' }}</td><td>{{ d.attempts if d.attempts is not none else '-' }}</td><td>{{ d.retry_count if d.retry_count is not none else '-' }}</td><td>{{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }}</td><td>{{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }}</td><td>{{ d.duration_ms if d.duration_ms is not none else '-' }}</td><td>{{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }}</td><td>{{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }}</td><td>{{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }}</td><td>{{ d.message or '-' }}{% if d.fallback_used is sameas true %}<div><em>Live request failed. Cached data was used.</em></div>{% endif %}{% if d.details %}<details><summary>Details</summary><pre>{{ d.details | tojson(indent=2) }}</pre></details>{% endif %}</td></tr>{% endfor %}</table>{% else %}<p>No source diagnostics available.</p>{% endif %}

<h2>Rohdaten</h2>
<h3>RPKI Rohdaten</h3>
Expand Down
4 changes: 2 additions & 2 deletions backend/app/templates/report.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@

## Data Source Diagnostics
{% if report.details and report.details.source_diagnostics %}
| Quelle | Endpoint | Status | Dauer | Freshness | Cache | Cache Age | TTL | Message |
| Quelle | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Dauer | Cache | Cache Age | TTL | Message |
|---|---|---|---:|---|---|
{% for d in report.details.source_diagnostics %}| {{ d.name or '-' }} | {{ d.endpoint or '-' }} | {{ d.status or '-' }} | {{ d.duration_ms if d.duration_ms is not none else '-' }} | {{ d.freshness or 'UNKNOWN' }} | {{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }} | {{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }} | {{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }} | {{ d.message or '-' }} |
{% for d in report.details.source_diagnostics %}| {{ d.name or '-' }} | {{ d.endpoint or '-' }} | {{ d.status or '-' }} | {{ d.freshness or 'UNKNOWN' }} | {{ d.attempts if d.attempts is not none else '-' }} | {{ d.retry_count if d.retry_count is not none else '-' }} | {{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }} | {{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }} | {{ d.duration_ms if d.duration_ms is not none else '-' }} | {{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }} | {{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }} | {{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }} | {{ d.message or '-' }}{% if d.fallback_used is sameas true %} (Live request failed. Cached data was used.){% endif %} |
{% endfor %}
{% else %}No source diagnostics available.
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
version = "0.4.1"
version = "0.4.2"
description = "RouteForge backend"
requires-python = ">=3.12"
dependencies = [
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_prefix_check_without_origin_as() -> None:
assert payload.get('markdown')
assert payload.get('html')
assert payload.get('details', {}).get('resource_holder')
assert any('freshness' in d for d in payload.get('details', {}).get('source_diagnostics', []) if isinstance(d, dict))
assert any('attempts' in d for d in payload.get('details', {}).get('source_diagnostics', []) if isinstance(d, dict))


def test_asn_check() -> None:
Expand All @@ -48,7 +48,7 @@ def test_asn_check() -> None:
assert 'extracted_prefixes' in details
assert details.get('rpki_batch', {}).get('available') is True
assert details.get('resource_holder')
assert any('freshness' in d for d in details.get('source_diagnostics', []) if isinstance(d, dict))
assert any('attempts' in d for d in details.get('source_diagnostics', []) if isinstance(d, dict))


def test_asn_check_without_prefixes_has_batch_reason() -> None:
Expand Down Expand Up @@ -114,7 +114,7 @@ def test_preflight_check() -> None:
assert 'routing_visibility' in checks
assert payload.get('details', {}).get('preflight_mode') is True
assert isinstance(payload.get('details', {}).get('source_diagnostics'), list)
assert any('freshness' in d for d in payload.get('details', {}).get('source_diagnostics', []) if isinstance(d, dict))
assert any('attempts' in d for d in payload.get('details', {}).get('source_diagnostics', []) if isinstance(d, dict))
assert payload.get('details', {}).get('resource_holder')
assert payload.get('details', {}).get('preflight_decision') in {'GO', 'CAUTION', 'NO-GO', 'UNKNOWN'}

Expand Down
75 changes: 61 additions & 14 deletions backend/tests/test_ripe_stat_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import httpx

from app.config import Settings
from app.services.ripe_stat_client import RipeStatClient

Expand All @@ -21,28 +23,73 @@ def commit(self):
return None


def _resp(code=200, payload=None, headers=None):
req = httpx.Request('GET', 'https://x')
return httpx.Response(code, json=payload or {'data': {'ok': True}}, headers=headers, request=req)


def test_demo_mode_diagnostics_live(monkeypatch):
import app.config as config

monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=True))
client = RipeStatClient(DummyDb())
_, diag = client.get_with_diagnostics('as-overview', {'resource': 'AS3320'})
assert diag['attempts'] == 1 and diag['retry_count'] == 0


def test_timeout_then_success(monkeypatch):
import app.config as config
from app.services import ripe_stat_client as module
monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=False, ripestat_max_retries=1))
calls = {'n': 0}
def fake_get(self, *_a, **_k):
calls['n'] += 1
if calls['n'] == 1:
raise httpx.TimeoutException('timeout')
return _resp()
monkeypatch.setattr(httpx.Client, 'get', fake_get)
monkeypatch.setattr(module, 'set_cached', lambda *_a, **_k: None)
payload, diag = RipeStatClient(DummyDb()).get_with_diagnostics('routing-status', {'resource': '1.1.1.0/24'}, force_refresh=True)
assert payload is not None and diag['status'] == 'OK' and diag['attempts'] == 2 and diag['retry_count'] == 1

client = RipeStatClient(DummyDb())
_payload, diag = client.get_with_diagnostics('as-overview', {'resource': 'AS3320'})
assert diag['cached'] is False
assert diag['freshness'] == 'LIVE'
assert diag['message'] == 'Demo data returned'

def test_503_then_success(monkeypatch):
import app.config as config
monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ripestat_max_retries=1))
seq = iter([_resp(503), _resp(200)])
monkeypatch.setattr(httpx.Client, 'get', lambda self,*a,**k: next(seq))
payload, diag = RipeStatClient(DummyDb()).get_with_diagnostics('whois', {'resource': '1.1.1.0/24'}, force_refresh=True)
assert payload is not None and diag['attempts'] == 2 and diag['status'] == 'OK'

def test_cached_response_has_age(monkeypatch):

def test_404_no_retry(monkeypatch):
import app.config as config
monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ripestat_max_retries=3))
monkeypatch.setattr(httpx.Client, 'get', lambda self,*a,**k: _resp(404))
payload, diag = RipeStatClient(DummyDb()).get_with_diagnostics('whois', {'resource': 'x'}, force_refresh=True)
assert payload is None and diag['attempts'] == 1 and diag['status'] == 'HTTP_ERROR'


def test_429_rate_limited(monkeypatch):
import app.config as config
monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ripestat_max_retries=3))
monkeypatch.setattr(httpx.Client, 'get', lambda self,*a,**k: _resp(429, headers={'Retry-After': '60'}))
_payload, diag = RipeStatClient(DummyDb()).get_with_diagnostics('whois', {'resource': 'x'}, force_refresh=True)
assert diag['status'] == 'RATE_LIMITED' and diag['details'].get('retry_after') == '60'


monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=False))
def test_timeout_with_stale_cache(monkeypatch):
import app.config as config
from app.services import ripe_stat_client as module
monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ripestat_max_retries=0, ripestat_use_stale_cache_on_error=True))
monkeypatch.setattr(httpx.Client, 'get', lambda self,*a,**k: (_ for _ in ()).throw(httpx.TimeoutException('t')))
monkeypatch.setattr(module, 'get_cached', lambda *_a, **_k: {'payload': {'data': {'cached': True}}})
payload, diag = RipeStatClient(DummyDb()).get_with_diagnostics('whois', {'resource': 'x'}, force_refresh=True)
assert payload == {'data': {'cached': True}} and diag['fallback_used'] is True and diag['stale_cache_used'] is True and diag['freshness'] == 'STALE'

monkeypatch.setattr(module, 'get_cached', lambda *_args, **_kwargs: {'payload': {'data': {}}, 'fetched_at': '2026-05-20T00:00:00+00:00', 'ttl_seconds': 900})
client = RipeStatClient(DummyDb())
_payload, diag = client.get_with_diagnostics('as-overview', {'resource': 'AS3320'})
assert diag['cached'] is True
assert isinstance(diag['cache_age_seconds'], int)
assert diag['cache_ttl_seconds'] == 900

def test_timeout_no_cache(monkeypatch):
import app.config as config
monkeypatch.setattr(config, 'settings', Settings(_env_file=None, ripestat_max_retries=0, ripestat_use_stale_cache_on_error=True))
monkeypatch.setattr(httpx.Client, 'get', lambda self,*a,**k: (_ for _ in ()).throw(httpx.TimeoutException('t')))
payload, diag = RipeStatClient(DummyDb()).get_with_diagnostics('whois', {'resource': 'x'}, force_refresh=True)
assert payload is None and diag['status'] == 'TIMEOUT'
8 changes: 8 additions & 0 deletions backend/tests/test_source_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ def test_make_cache_metadata_freshness_rules() -> None:
def test_known_status_values_present() -> None:
for value in [OK, TIMEOUT, "HTTP_ERROR", "PARSE_ERROR", "UNKNOWN_STRUCTURE", "RATE_LIMITED", "CACHE_HIT", "CACHE_MISS", "EMPTY_RESPONSE", "NO_DATA", "ERROR"]:
assert value in KNOWN_SOURCE_STATUSES


def test_make_source_diagnostic_retry_fields() -> None:
d = make_source_diagnostic("n", "e", OK, "m", attempts=2, retry_count=1, fallback_used=True, stale_cache_used=True)
assert d["attempts"] == 2
assert d["retry_count"] == 1
assert d["fallback_used"] is True
assert d["stale_cache_used"] is True
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "routeforge-frontend",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading