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:
@@ -15,6 +15,7 @@ import type {
|
||||
import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
|
||||
|
||||
const DEFAULT_BASE_URL = "";
|
||||
const UNAVAILABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
|
||||
|
||||
type SearchJourneyOptions = {
|
||||
arriveBy?: boolean;
|
||||
@@ -92,14 +93,19 @@ function toStation(location: HafasLocationWithCoords): Station {
|
||||
* Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app.
|
||||
*/
|
||||
export class ApiClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly baseUrls: string[];
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
||||
constructor(baseUrl?: string | string[]) {
|
||||
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 }> {
|
||||
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}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -107,8 +113,7 @@ export class ApiClient {
|
||||
async geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]> {
|
||||
const params: Record<string, string> = { name };
|
||||
if (countrycodes) params.countrycodes = countrycodes;
|
||||
const url = buildUrl(this.baseUrl, "/api/geocode", params);
|
||||
const res = await fetch(url);
|
||||
const res = await this.fetchApi("/api/geocode", undefined, params);
|
||||
if (!res.ok) throw new Error(`Geocode failed: ${res.status}`);
|
||||
const result: GeocodeResult = await res.json();
|
||||
return [result];
|
||||
@@ -120,13 +125,12 @@ export class ApiClient {
|
||||
toLat: number,
|
||||
toLng: number,
|
||||
): Promise<BikeRoute> {
|
||||
const url = buildUrl(this.baseUrl, "/api/bike-route", {
|
||||
const res = await this.fetchApi("/api/bike-route", undefined, {
|
||||
fromLat: String(fromLat),
|
||||
fromLng: String(fromLng),
|
||||
toLat: String(toLat),
|
||||
toLng: String(toLng),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Bike route failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -137,13 +141,12 @@ export class ApiClient {
|
||||
toLat: number,
|
||||
toLng: number,
|
||||
): Promise<WalkRoute> {
|
||||
const url = buildUrl(this.baseUrl, "/api/walk-route", {
|
||||
const res = await this.fetchApi("/api/walk-route", undefined, {
|
||||
fromLat: String(fromLat),
|
||||
fromLng: String(fromLng),
|
||||
toLat: String(toLat),
|
||||
toLng: String(toLng),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Walk route failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -151,14 +154,13 @@ export class ApiClient {
|
||||
async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> {
|
||||
const params: Record<string, string> = { url };
|
||||
if (days) params.days = String(days);
|
||||
const api = buildUrl(this.baseUrl, "/api/calendar", params);
|
||||
const res = await fetch(api);
|
||||
const res = await this.fetchApi("/api/calendar", undefined, params);
|
||||
if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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",
|
||||
headers: { "Content-Type": "text/calendar" },
|
||||
body: content,
|
||||
@@ -172,7 +174,7 @@ export class ApiClient {
|
||||
* This handles TripSearch, LocMatch, and any other HAFAS methods.
|
||||
*/
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
@@ -209,7 +211,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
@@ -221,22 +223,20 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
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),
|
||||
lng: String(lng),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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),
|
||||
lng: String(lng),
|
||||
radius: String(radius),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Nearby stops failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.stops ?? [];
|
||||
@@ -331,10 +331,36 @@ export class ApiClient {
|
||||
for (const id of stopIds) {
|
||||
params.append("stopIds", id);
|
||||
}
|
||||
const url = `${this.baseUrl}/api/wienerlinien/monitor?${params.toString()}`;
|
||||
const res = await fetch(url);
|
||||
const res = await this.fetchApi(`/api/wienerlinien/monitor?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`Monitor failed: ${res.status}`);
|
||||
const data: { departures?: WienerLinienDeparture[] } = await res.json();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user