Compare commits

...
Author SHA1 Message Date
Anthony Stirling 5dc0c79057 Scope frontend load balancer to proprietary UI 2025-11-16 23:05:51 +00:00
9 changed files with 237 additions and 9 deletions
+1
View File
@@ -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)
+5
View File
@@ -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.
---
@@ -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';
}, []);
@@ -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';
}
@@ -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;
@@ -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);
}
@@ -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<string, number>();
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,
};
}
+3
View File
@@ -4,6 +4,9 @@
"paths": {
"@app/*": [
"src/core/*"
],
"@core/*": [
"src/core/*"
]
}
},
+5
View File
@@ -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 {