diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ed22f..19a6af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). +## [33.1.0] - 2026-07-03 + +### Automatischer API-Failover + +#### Added +- **Failover auf v6.bvg.transport.rest**: Wenn die primäre VBB-API nicht antwortet (Netzwerkfehler, Timeout, HTTP 5xx), schaltet die App automatisch auf die baugleiche BVG-API um - gleicher Betreiber, identische Routen und Stop-IDs, deckt ebenfalls ganz Berlin/Brandenburg ab, nutzt aber eine andere Upstream-Quelle. Nach 10 Minuten wird die primäre API automatisch wieder probiert. +- **12s-Timeout pro Request** (AbortController): hängende Verbindungen blockieren die App nicht mehr und lösen den Failover aus. +- HTTP-4xx-Fehler lösen bewusst KEINEN Failover aus (fehlerhafte Anfrage, kein API-Ausfall). +- Sind beide APIs down, werden notfalls abgelaufene Cache-Daten geliefert. + ## [33.0.0] - 2026-07-03 ### Favoriten, Persistenz, echte Filter & Modul-Architektur diff --git a/js/api.js b/js/api.js index 4d0e7da..ac35ddb 100644 --- a/js/api.js +++ b/js/api.js @@ -4,7 +4,15 @@ // VBB NETZ STATUS - Haupt-Script // ========================================== - const API_BASE = 'https://v6.vbb.transport.rest'; + // Primäre API + baugleicher Fallback (gleicher Betreiber, gleiche + // Routen/Stop-IDs, andere Upstream-Quelle - siehe apiFetch) + const API_BASES = [ + 'https://v6.vbb.transport.rest', + 'https://v6.bvg.transport.rest' + ]; + const API_BASE = API_BASES[0]; // Call-Sites bauen URLs immer mit der primären Basis + let activeApiBaseIndex = 0; // 0 = VBB, 1 = BVG-Fallback + let apiFallbackSince = 0; // Zeitpunkt des Failovers (für Recovery) let currentStationId = null; let currentStationName = null; let searchTimeout = null; @@ -61,6 +69,24 @@ * @returns {Promise} JSON-Daten * Wirft Error('RATE_LIMIT') wenn Budget aufgebraucht (Cache wird trotzdem genutzt). */ + /** + * Zentraler API-Aufruf mit automatischem Failover. + * + * Primär: v6.vbb.transport.rest + * Fallback: v6.bvg.transport.rest (gleicher Betreiber, identische + * Routen & Stop-IDs, deckt ebenfalls ganz Berlin/Brandenburg + * ab - nutzt aber eine ANDERE Upstream-Quelle. Fällt VBB + * aus, läuft die App über BVG weiter.) + * + * - Timeout 12s pro Versuch (hängende Requests blockieren nichts) + * - Failover bei Netzwerkfehler, Timeout oder HTTP 5xx + * (NICHT bei 4xx - das ist ein Anfragefehler, kein API-Ausfall) + * - Nach 10 Minuten auf dem Fallback wird die primäre API erneut probiert + * - Cache-Keys basieren auf der primären URL -> Cache übersteht Failover + * + * @param {string} url - vollständige URL (mit API_BASE gebaut) + * @param {object} opts - { ttl: Cache-Dauer in ms (0 = kein Cache) } + */ async function apiFetch(url, { ttl = 0 } = {}) { // 1) Frischer Cache-Treffer? -> kein Request nötig const cached = apiCache.get(url); @@ -82,28 +108,90 @@ throw new Error('RATE_LIMIT'); } - apiCallTimestamps.push(Date.now()); + const promise = (async () => { + // Recovery: Nach 10 min auf dem Fallback wieder primär versuchen + if (activeApiBaseIndex !== 0 && + Date.now() - apiFallbackSince > 10 * 60 * 1000) { + activeApiBaseIndex = 0; + console.warn('API-Failover: Versuche wieder die primäre VBB-API'); + } - const promise = fetch(url) - .then(async (response) => { - if (!response.ok) throw new Error('HTTP ' + response.status); - const data = await response.json(); - if (ttl > 0) { - // Cache-Größe begrenzen (ältesten Eintrag entfernen) - if (apiCache.size >= API_CACHE_MAX_ENTRIES) { - const oldestKey = apiCache.keys().next().value; - apiCache.delete(oldestKey); + const tryOrder = activeApiBaseIndex === 0 ? [0, 1] : [1, 0]; + let lastError = null; + + for (const idx of tryOrder) { + const realUrl = url.replace(API_BASES[0], API_BASES[idx]); + apiCallTimestamps.push(Date.now()); // jeder echte Versuch zählt + + try { + const response = await fetchWithTimeout(realUrl, 12000); + + if (!response.ok) { + if (response.status >= 500) { + // Serverfehler -> Failover lohnt sich + throw new Error('HTTP ' + response.status); + } + // 4xx: Anfrage ist falsch - andere API würde genauso antworten + const err = new Error('HTTP ' + response.status); + err.noFailover = true; + throw err; } - apiCache.set(url, { data, time: Date.now(), ttl }); + + const data = await response.json(); + + // Basis-Wechsel dokumentieren + if (idx !== activeApiBaseIndex) { + activeApiBaseIndex = idx; + if (idx !== 0) { + apiFallbackSince = Date.now(); + console.warn(`API-Failover aktiv: ${API_BASES[idx]} (VBB-API antwortet nicht)`); + } else { + console.warn('API-Failover beendet: primäre VBB-API antwortet wieder'); + } + } + + if (ttl > 0) { + // Cache-Größe begrenzen (ältesten Eintrag entfernen) + if (apiCache.size >= API_CACHE_MAX_ENTRIES) { + const oldestKey = apiCache.keys().next().value; + apiCache.delete(oldestKey); + } + apiCache.set(url, { data, time: Date.now(), ttl }); + } + return data; + + } catch (e) { + if (e.noFailover) throw e; + lastError = e; + // Budget für den zweiten Versuch noch da? + if (apiBudgetLeft() <= 0) break; + // -> nächste Basis probieren } - return data; - }) - .finally(() => apiInflight.delete(url)); + } + + // Beide APIs down: notfalls abgelaufenen Cache liefern + if (cached) { + console.warn('Beide APIs nicht erreichbar – liefere veraltete Cache-Daten'); + return cached.data; + } + throw lastError || new Error('API nicht erreichbar'); + })().finally(() => apiInflight.delete(url)); apiInflight.set(url, promise); return promise; } + // fetch mit hartem Timeout (hängende Verbindungen brechen sauber ab) + async function fetchWithTimeout(realUrl, ms) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + try { + return await fetch(realUrl, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + // HTML-Escaping für alle Strings aus der API (XSS-Schutz) function escapeHtml(str) { if (str === null || str === undefined) return ''; diff --git a/package.json b/package.json index 2aa9774..eb1c4a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vbb-netz-status", - "version": "33.0.0", + "version": "33.1.0", "description": "Live-Nahverkehr für Berlin & Brandenburg - Progressive Web App (PWA) für Echtzeit-Abfahrten, Routenplanung und Live-Fahrzeugverfolgung im VBB-Gebiet", "main": "server.js", "scripts": {