Support multiple API base URLs with fallback

Support Multiple API Base URLs With Fallback

Make ApiClient accept multiple base URLs and try the next when
responses indicate unavailability (408, 429, 502, 503, 504 or any >=500)
or on network errors. Add fetchApi helper and selection logic, and use a
Tailscale dev server as a fallback in the mobile service. Also update
EventHeader to show a live leave-by countdown.
This commit is contained in:
2026-05-18 12:43:04 +02:00
parent 44fb492759
commit b3edf2c47b
3 changed files with 75 additions and 35 deletions
+23 -12
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { calculateCountdown } from '@timetoleave/core';
import type { Event } from '@timetoleave/core'; import type { Event } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
@@ -11,25 +13,34 @@ interface Props {
} }
/** /**
* Renders the event title, destination, formatted date/time, data source, * Renders the event title, destination, leave countdown, data source, and a
* and a three-column grid with leave-by time, arrive-by time, and buffer. * three-column grid with leave-by time, arrive-by time, and buffer.
*/ */
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) { export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
const [, setTick] = useState(0);
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000); const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
const leaveCountdown = leaveByTime ? calculateCountdown(leaveByTime) : null;
useEffect(() => {
const interval = setInterval(() => setTick((tick) => tick + 1), 30_000);
return () => clearInterval(interval);
}, []);
return ( return (
<View style={[styles.container, { backgroundColor: colors.card }]}> <View style={[styles.container, { backgroundColor: colors.card }]}>
<Text style={[styles.title, { color: colors.text }]}>{event.title}</Text> <Text style={[styles.title, { color: colors.text }]}>{event.title}</Text>
<Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text> <Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text>
<Text style={[styles.time, { color: colors.accent }]}> <Text
{event.eventTime.toLocaleString('de-AT', { style={[
weekday: 'long', styles.countdown,
day: '2-digit', { color: leaveCountdown?.urgent ? colors.delete : colors.text },
month: '2-digit', ]}
year: 'numeric', >
hour: '2-digit', {leaveCountdown
minute: '2-digit', ? leaveCountdown.label === 'Now'
})} ? 'Jetzt losgehen'
: `Noch ${leaveCountdown.label} bis Losgehen`
: 'Countdown wird berechnet'}
</Text> </Text>
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text> <Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
@@ -61,7 +72,7 @@ const styles = StyleSheet.create({
container: { padding: 20, marginBottom: 12 }, container: { padding: 20, marginBottom: 12 },
title: { fontSize: 22, fontWeight: '700' }, title: { fontSize: 22, fontWeight: '700' },
destination: { fontSize: 16, marginTop: 4 }, destination: { fontSize: 16, marginTop: 4 },
time: { fontSize: 14, marginTop: 8 }, countdown: { fontSize: 18, fontWeight: '700', marginTop: 10 },
source: { fontSize: 12, marginTop: 4 }, source: { fontSize: 12, marginTop: 4 },
infoGrid: { infoGrid: {
flexDirection: 'row', flexDirection: 'row',
+5 -2
View File
@@ -2,7 +2,10 @@ import { ApiClient } from '@timetoleave/api-client';
/** /**
* Thin wrapper around the shared `ApiClient` class. * Thin wrapper around the shared `ApiClient` class.
* Reads the base URL from the Expo environment variable `EXPO_PUBLIC_API_BASE_URL`. * Reads the primary base URL from `EXPO_PUBLIC_API_BASE_URL` and falls back
* to the Tailscale dev server when the primary server is unreachable.
*/ */
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? ''; const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
export const api = new ApiClient(baseUrl); const fallbackBaseUrl = 'http://100.103.83.12:3030';
export const api = new ApiClient([baseUrl, fallbackBaseUrl]);
+47 -21
View File
@@ -15,6 +15,7 @@ import type {
import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core"; import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
const DEFAULT_BASE_URL = ""; const DEFAULT_BASE_URL = "";
const UNAVAILABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
type SearchJourneyOptions = { type SearchJourneyOptions = {
arriveBy?: boolean; arriveBy?: boolean;
@@ -92,14 +93,19 @@ function toStation(location: HafasLocationWithCoords): Station {
* Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app. * Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app.
*/ */
export class ApiClient { export class ApiClient {
private readonly baseUrl: string; private readonly baseUrls: string[];
constructor(baseUrl?: string) { constructor(baseUrl?: string | string[]) {
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL; const baseUrls = Array.isArray(baseUrl) ? baseUrl : [baseUrl ?? DEFAULT_BASE_URL];
this.baseUrls = Array.from(new Set(baseUrls.map((url) => url?.trim()).filter((url): url is string => Boolean(url))));
if (this.baseUrls.length === 0) {
this.baseUrls = [DEFAULT_BASE_URL];
}
} }
async getHealth(): Promise<{ status: "ok"; uptime: number }> { async getHealth(): Promise<{ status: "ok"; uptime: number }> {
const res = await fetch(`${this.baseUrl}/api/health`); const res = await this.fetchApi("/api/health");
if (!res.ok) throw new Error(`Health check failed: ${res.status}`); if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
return res.json(); return res.json();
} }
@@ -107,8 +113,7 @@ export class ApiClient {
async geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]> { async geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]> {
const params: Record<string, string> = { name }; const params: Record<string, string> = { name };
if (countrycodes) params.countrycodes = countrycodes; if (countrycodes) params.countrycodes = countrycodes;
const url = buildUrl(this.baseUrl, "/api/geocode", params); const res = await this.fetchApi("/api/geocode", undefined, params);
const res = await fetch(url);
if (!res.ok) throw new Error(`Geocode failed: ${res.status}`); if (!res.ok) throw new Error(`Geocode failed: ${res.status}`);
const result: GeocodeResult = await res.json(); const result: GeocodeResult = await res.json();
return [result]; return [result];
@@ -120,13 +125,12 @@ export class ApiClient {
toLat: number, toLat: number,
toLng: number, toLng: number,
): Promise<BikeRoute> { ): Promise<BikeRoute> {
const url = buildUrl(this.baseUrl, "/api/bike-route", { const res = await this.fetchApi("/api/bike-route", undefined, {
fromLat: String(fromLat), fromLat: String(fromLat),
fromLng: String(fromLng), fromLng: String(fromLng),
toLat: String(toLat), toLat: String(toLat),
toLng: String(toLng), toLng: String(toLng),
}); });
const res = await fetch(url);
if (!res.ok) throw new Error(`Bike route failed: ${res.status}`); if (!res.ok) throw new Error(`Bike route failed: ${res.status}`);
return res.json(); return res.json();
} }
@@ -137,13 +141,12 @@ export class ApiClient {
toLat: number, toLat: number,
toLng: number, toLng: number,
): Promise<WalkRoute> { ): Promise<WalkRoute> {
const url = buildUrl(this.baseUrl, "/api/walk-route", { const res = await this.fetchApi("/api/walk-route", undefined, {
fromLat: String(fromLat), fromLat: String(fromLat),
fromLng: String(fromLng), fromLng: String(fromLng),
toLat: String(toLat), toLat: String(toLat),
toLng: String(toLng), toLng: String(toLng),
}); });
const res = await fetch(url);
if (!res.ok) throw new Error(`Walk route failed: ${res.status}`); if (!res.ok) throw new Error(`Walk route failed: ${res.status}`);
return res.json(); return res.json();
} }
@@ -151,14 +154,13 @@ export class ApiClient {
async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> { async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> {
const params: Record<string, string> = { url }; const params: Record<string, string> = { url };
if (days) params.days = String(days); if (days) params.days = String(days);
const api = buildUrl(this.baseUrl, "/api/calendar", params); const res = await this.fetchApi("/api/calendar", undefined, params);
const res = await fetch(api);
if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`); if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`);
return res.json(); return res.json();
} }
async parseCalendarIcs(content: string): Promise<CalendarEvent[]> { async parseCalendarIcs(content: string): Promise<CalendarEvent[]> {
const res = await fetch(`${this.baseUrl}/api/calendar/parse`, { const res = await this.fetchApi("/api/calendar/parse", {
method: "POST", method: "POST",
headers: { "Content-Type": "text/calendar" }, headers: { "Content-Type": "text/calendar" },
body: content, body: content,
@@ -172,7 +174,7 @@ export class ApiClient {
* This handles TripSearch, LocMatch, and any other HAFAS methods. * This handles TripSearch, LocMatch, and any other HAFAS methods.
*/ */
async hafasRequest<T = Record<string, unknown>>(body: unknown): Promise<T> { async hafasRequest<T = Record<string, unknown>>(body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/hafas`, { const res = await this.fetchApi("/api/hafas", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -209,7 +211,7 @@ export class ApiClient {
} }
private async fetchTripSearch(body: unknown, hafasDate: string, queryDate: Date): Promise<Journey[]> { private async fetchTripSearch(body: unknown, hafasDate: string, queryDate: Date): Promise<Journey[]> {
const res = await fetch(`${this.baseUrl}/api/hafas`, { const res = await this.fetchApi("/api/hafas", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -221,22 +223,20 @@ export class ApiClient {
} }
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> { async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
const url = buildUrl(this.baseUrl, "/api/geocode/reverse", { const res = await this.fetchApi("/api/geocode/reverse", undefined, {
lat: String(lat), lat: String(lat),
lng: String(lng), lng: String(lng),
}); });
const res = await fetch(url);
if (!res.ok) return null; if (!res.ok) return null;
return res.json(); return res.json();
} }
async findNearbyStops(lat: number, lng: number, radius: number = 1000): Promise<NearbyStop[]> { async findNearbyStops(lat: number, lng: number, radius: number = 1000): Promise<NearbyStop[]> {
const url = buildUrl(this.baseUrl, "/api/wienerlinien/stops", { const res = await this.fetchApi("/api/wienerlinien/stops", undefined, {
lat: String(lat), lat: String(lat),
lng: String(lng), lng: String(lng),
radius: String(radius), radius: String(radius),
}); });
const res = await fetch(url);
if (!res.ok) throw new Error(`Nearby stops failed: ${res.status}`); if (!res.ok) throw new Error(`Nearby stops failed: ${res.status}`);
const data = await res.json(); const data = await res.json();
return data.stops ?? []; return data.stops ?? [];
@@ -331,10 +331,36 @@ export class ApiClient {
for (const id of stopIds) { for (const id of stopIds) {
params.append("stopIds", id); params.append("stopIds", id);
} }
const url = `${this.baseUrl}/api/wienerlinien/monitor?${params.toString()}`; const res = await this.fetchApi(`/api/wienerlinien/monitor?${params.toString()}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`Monitor failed: ${res.status}`); if (!res.ok) throw new Error(`Monitor failed: ${res.status}`);
const data: { departures?: WienerLinienDeparture[] } = await res.json(); const data: { departures?: WienerLinienDeparture[] } = await res.json();
return data.departures ?? []; return data.departures ?? [];
} }
private async fetchApi(path: string, init?: RequestInit, params?: Record<string, string>): Promise<Response> {
let lastError: unknown;
let lastResponse: Response | null = null;
for (const baseUrl of this.baseUrls) {
try {
const res = await fetch(buildUrl(baseUrl, path, params), init);
if (res.ok || !this.shouldTryNextBaseUrl(res.status)) {
return res;
}
lastResponse = res;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("API request failed");
}
private shouldTryNextBaseUrl(status: number): boolean {
return status >= 500 || UNAVAILABLE_STATUSES.has(status);
}
} }