From 5dc0c7905782d291b11344017ab4e3cf187068cd Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:05:51 +0000 Subject: [PATCH] Scope frontend load balancer to proprietary UI --- docker/README.md | 1 + docker/unified/README.md | 5 + .../src/desktop/hooks/useEndpointConfig.ts | 5 +- .../src/desktop/services/apiClientConfig.ts | 39 ++++-- .../src/proprietary/services/apiClient.ts | 39 ++++++ .../proprietary/services/apiClientConfig.ts | 30 +++++ .../proprietary/services/backendBalancer.ts | 119 ++++++++++++++++++ frontend/tsconfig.core.json | 3 + frontend/vite-env.d.ts | 5 + 9 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 frontend/src/proprietary/services/apiClient.ts create mode 100644 frontend/src/proprietary/services/apiClientConfig.ts create mode 100644 frontend/src/proprietary/services/backendBalancer.ts diff --git a/docker/README.md b/docker/README.md index 99b86b53ea..031ff108e4 100644 --- a/docker/README.md +++ b/docker/README.md @@ -47,6 +47,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build ## Configuration - **Backend URL**: Set `VITE_API_BASE_URL` environment variable for custom backend locations +- **Load-balanced backends**: Provide a comma-separated list via `VITE_API_BASE_URLS` (for example `https://api-1.example.com,https://api-2.example.com`) to let the web client distribute requests across multiple backend nodes without an external proxy - **Custom Ports**: Modify port mappings in docker-compose files - **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat) diff --git a/docker/unified/README.md b/docker/unified/README.md index 6f0488aa23..7a17536525 100644 --- a/docker/unified/README.md +++ b/docker/unified/README.md @@ -108,6 +108,7 @@ Swagger UI at: `http://localhost:8080/swagger-ui/index.html` | Variable | Default | Description | |----------|---------|-------------| | `VITE_API_BASE_URL` | `http://backend:8080` | Backend URL for API proxying | +| `VITE_API_BASE_URLS` | _(empty)_ | Optional comma-separated list of backend URLs for the React client to load balance across (requires the browser to reach those URLs directly) | ### Standard Configuration @@ -269,6 +270,10 @@ docker build \ MODE: FRONTEND VITE_API_BASE_URL: http://load-balancer:8080 ``` + You can also skip the external load balancer for browser clients by defining + `VITE_API_BASE_URLS` with a comma-separated list of backend URLs. The + Stirling-PDF frontend will round-robin requests across the provided hosts and + temporarily avoid nodes that return network errors/5xx responses. --- diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 2a8629ffd8..203af313c8 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import apiClient from '@app/services/apiClient'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { isBackendNotReadyError } from '@app/constants/backendErrors'; +import { parseBackendUrlList } from '@proprietary/services/backendBalancer'; interface EndpointConfig { backendUrl: string; @@ -241,10 +242,12 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { export function useEndpointConfig(): EndpointConfig { const backendUrl = useMemo(() => { const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined; + const apiBaseCandidates = parseBackendUrlList(import.meta.env.VITE_API_BASE_URLS); + const firstConfiguredBase = apiBaseCandidates[0] || import.meta.env.VITE_API_BASE_URL; return runtimeEnv?.STIRLING_BACKEND_URL || import.meta.env.VITE_DESKTOP_BACKEND_URL - || import.meta.env.VITE_API_BASE_URL + || firstConfiguredBase || 'http://localhost:8080'; }, []); diff --git a/frontend/src/desktop/services/apiClientConfig.ts b/frontend/src/desktop/services/apiClientConfig.ts index 3dfe2a4a7e..e097334c09 100644 --- a/frontend/src/desktop/services/apiClientConfig.ts +++ b/frontend/src/desktop/services/apiClientConfig.ts @@ -1,17 +1,40 @@ import { isTauri } from '@tauri-apps/api/core'; +import { + createBackendBalancer, + resolveConfiguredBackendUrls, + type BackendBalancer, + type BackendBalancerStrategy, +} from '@proprietary/services/backendBalancer'; + +const configuredUrls = resolveConfiguredBackendUrls(import.meta.env); +const strategy = (import.meta.env.VITE_API_BACKEND_STRATEGY as BackendBalancerStrategy | undefined) || 'round-robin'; +const cooldownEnv = Number.parseInt(import.meta.env.VITE_API_BACKEND_FAILURE_COOLDOWN_MS ?? '', 10); +const failureCooldownMs = Number.isFinite(cooldownEnv) ? cooldownEnv : undefined; + +const backendBalancer: BackendBalancer = createBackendBalancer(configuredUrls, { + strategy, + failureCooldownMs, +}); + +function getTauriBaseUrl(): string { + if (import.meta.env.DEV) { + return '/'; + } + return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080'; +} /** * Desktop override: Determine base URL depending on Tauri environment */ export function getApiBaseUrl(): string { if (!isTauri()) { - return import.meta.env.VITE_API_BASE_URL || '/'; + return backendBalancer.getNextBaseUrl(); + } + return getTauriBaseUrl(); +} + +export function reportBackendFailure(baseUrl?: string | null): void { + if (!isTauri()) { + backendBalancer.reportFailure(baseUrl ?? undefined); } - - if (import.meta.env.DEV) { - // During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight - return '/'; - } - - return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080'; } diff --git a/frontend/src/proprietary/services/apiClient.ts b/frontend/src/proprietary/services/apiClient.ts new file mode 100644 index 0000000000..73afeadde3 --- /dev/null +++ b/frontend/src/proprietary/services/apiClient.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; +import { handleHttpError } from '@app/services/httpErrorHandler'; +import { setupApiInterceptors } from '@app/services/apiClientSetup'; +import { getApiBaseUrl, reportBackendFailure } from '@app/services/apiClientConfig'; + +// Create axios instance with default config +const apiClient = axios.create({ + responseType: 'json', +}); + +apiClient.interceptors.request.use((config) => { + if (!config.baseURL) { + config.baseURL = getApiBaseUrl(); + } + return config; +}); + +// Setup interceptors (core does nothing, proprietary adds JWT auth) +setupApiInterceptors(apiClient); + +// ---------- Install error interceptor ---------- +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const isServerOrNetworkFailure = !status || status >= 500; + if (isServerOrNetworkFailure) { + reportBackendFailure(error.config?.baseURL); + } + } + await handleHttpError(error); // Handle error (shows toast unless suppressed) + return Promise.reject(error); + } +); + + +// ---------- Exports ---------- +export default apiClient; diff --git a/frontend/src/proprietary/services/apiClientConfig.ts b/frontend/src/proprietary/services/apiClientConfig.ts new file mode 100644 index 0000000000..fe84d18768 --- /dev/null +++ b/frontend/src/proprietary/services/apiClientConfig.ts @@ -0,0 +1,30 @@ +import { + createBackendBalancer, + resolveConfiguredBackendUrls, + type BackendBalancer, + type BackendBalancerStrategy, +} from '@proprietary/services/backendBalancer'; + +const configuredUrls = resolveConfiguredBackendUrls(import.meta.env); +const strategy = (import.meta.env.VITE_API_BACKEND_STRATEGY as BackendBalancerStrategy | undefined) || 'round-robin'; +const cooldownEnv = Number.parseInt(import.meta.env.VITE_API_BACKEND_FAILURE_COOLDOWN_MS ?? '', 10); +const failureCooldownMs = Number.isFinite(cooldownEnv) ? cooldownEnv : undefined; + +const backendBalancer: BackendBalancer = createBackendBalancer(configuredUrls, { + strategy, + failureCooldownMs, +}); + +/** + * Select the next backend base URL using the configured strategy. + */ +export function getApiBaseUrl(): string { + return backendBalancer.getNextBaseUrl(); +} + +/** + * Notify the balancer that a backend failed so it can be temporarily deprioritized. + */ +export function reportBackendFailure(baseUrl?: string | null): void { + backendBalancer.reportFailure(baseUrl ?? undefined); +} diff --git a/frontend/src/proprietary/services/backendBalancer.ts b/frontend/src/proprietary/services/backendBalancer.ts new file mode 100644 index 0000000000..dd15ce54af --- /dev/null +++ b/frontend/src/proprietary/services/backendBalancer.ts @@ -0,0 +1,119 @@ +export type BackendBalancerStrategy = 'round-robin' | 'random'; + +export interface BackendBalancerOptions { + strategy?: BackendBalancerStrategy; + failureCooldownMs?: number; +} + +export interface BackendBalancer { + getNextBaseUrl(): string; + getAllBaseUrls(): string[]; + reportFailure(baseUrl?: string | null): void; +} + +const DEFAULT_BASE_URL = '/'; +const DEFAULT_COOLDOWN_MS = 15000; + +export function sanitizeBaseUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed === '/') return '/'; + return trimmed.replace(/\/+$/, ''); +} + +export function parseBackendUrlList(raw?: string | string[] | null): string[] { + if (!raw) return []; + const parts = Array.isArray(raw) + ? raw + : raw + .split(/[,\s]+/) + .map((segment) => segment.trim()) + .filter(Boolean); + + const normalized: string[] = []; + for (const candidate of parts) { + const sanitized = sanitizeBaseUrl(candidate); + if (sanitized && !normalized.includes(sanitized)) { + normalized.push(sanitized); + } + } + return normalized; +} + +interface EnvLike { + [key: string]: string | undefined; +} + +export function resolveConfiguredBackendUrls( + env?: EnvLike, + fallback: string = DEFAULT_BASE_URL +): string[] { + const rawMulti = env?.VITE_API_BASE_URLS; + const rawSingle = env?.VITE_API_BASE_URL; + const parsed = [rawMulti, rawSingle] + .flatMap((value) => parseBackendUrlList(value)) + .filter(Boolean); + + if (parsed.length > 0) { + return parsed; + } + + const sanitizedFallback = sanitizeBaseUrl(fallback); + return sanitizedFallback ? [sanitizedFallback] : [DEFAULT_BASE_URL]; +} + +export function createBackendBalancer( + urls: string[], + options?: BackendBalancerOptions +): BackendBalancer { + const unique = urls.length > 0 ? Array.from(new Set(urls.map(sanitizeBaseUrl).filter(Boolean))) : [DEFAULT_BASE_URL]; + let pointer = 0; + const penalties = new Map(); + const strategy: BackendBalancerStrategy = options?.strategy ?? 'round-robin'; + const cooldown = Math.max(0, options?.failureCooldownMs ?? DEFAULT_COOLDOWN_MS); + + function getCandidateRoundRobin(now: number): string { + for (let attempt = 0; attempt < unique.length; attempt += 1) { + const idx = (pointer + attempt) % unique.length; + const candidate = unique[idx]; + const penaltyExpires = penalties.get(candidate) ?? 0; + if (penaltyExpires <= now) { + pointer = (idx + 1) % unique.length; + return candidate; + } + } + pointer = (pointer + 1) % unique.length; + return unique[pointer]; + } + + function getCandidateRandom(now: number): string { + for (let attempt = 0; attempt < unique.length; attempt += 1) { + const idx = Math.floor(Math.random() * unique.length); + const candidate = unique[idx]; + const penaltyExpires = penalties.get(candidate) ?? 0; + if (penaltyExpires <= now) { + return candidate; + } + } + return unique[Math.floor(Math.random() * unique.length)]; + } + + function pickCandidate(): string { + const now = Date.now(); + return strategy === 'random' ? getCandidateRandom(now) : getCandidateRoundRobin(now); + } + + function reportFailure(baseUrl?: string | null): void { + if (!baseUrl) return; + const normalized = sanitizeBaseUrl(baseUrl); + if (!normalized) return; + const expiresAt = Date.now() + cooldown; + penalties.set(normalized, expiresAt); + } + + return { + getNextBaseUrl: pickCandidate, + getAllBaseUrls: () => [...unique], + reportFailure, + }; +} diff --git a/frontend/tsconfig.core.json b/frontend/tsconfig.core.json index a06e67c0f9..d187ba16c7 100644 --- a/frontend/tsconfig.core.json +++ b/frontend/tsconfig.core.json @@ -4,6 +4,9 @@ "paths": { "@app/*": [ "src/core/*" + ], + "@core/*": [ + "src/core/*" ] } }, diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index ca36e9027d..aa5611f785 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -3,6 +3,11 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_POSTHOG_KEY: string; readonly VITE_PUBLIC_POSTHOG_HOST: string; + readonly VITE_API_BASE_URL?: string; + readonly VITE_API_BASE_URLS?: string; + readonly VITE_API_BACKEND_STRATEGY?: 'round-robin' | 'random'; + readonly VITE_API_BACKEND_FAILURE_COOLDOWN_MS?: string; + readonly VITE_DESKTOP_BACKEND_URL?: string; } interface ImportMeta {