Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
/**
* Polled monitoring callers fail fast once the ApiClient circuit opens (#11682).
* One get() makes up to 3 attempts; each failed attempt counts toward the
* threshold, so the call after the first exhausted one must not reach fetch.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { resetCircuitBreakers } from '@/utils/apiCircuitBreaker'
import { useSystemMetrics } from '@/composables/usePrometheusMetrics'
import { useServiceMessages } from '@/composables/useServiceMessages'
import { useErrorMonitoring } from '@/composables/useErrorMonitoring'

async function settle(run: () => Promise<unknown>) {
const p = run()
await vi.advanceTimersByTimeAsync(10_000)
await p
}

describe('polled callers use the circuit breaker', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.useFakeTimers()
resetCircuitBreakers()
fetchMock = vi.fn().mockRejectedValue(new Error('network down'))
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})

it('useSystemMetrics stops hitting the network after the threshold', async () => {
const { fetch: poll } = useSystemMetrics()
await settle(poll)
const calls = fetchMock.mock.calls.length
expect(calls).toBeGreaterThanOrEqual(3)
await settle(poll)
expect(fetchMock.mock.calls.length).toBe(calls)
})

it('useServiceMessages.fetchLatest stops hitting the network after the threshold', async () => {
const { fetchLatest } = useServiceMessages()
await settle(() => fetchLatest())
const calls = fetchMock.mock.calls.length
expect(calls).toBeGreaterThanOrEqual(3)
await settle(() => fetchLatest())
expect(fetchMock.mock.calls.length).toBe(calls)
})

it('useErrorMonitoring.fetchStatistics stops hitting the network after the threshold', async () => {
const { fetchStatistics } = useErrorMonitoring({ autoFetch: false, pollInterval: 0 })
await settle(() => fetchStatistics())
const calls = fetchMock.mock.calls.length
expect(calls).toBeGreaterThanOrEqual(3)
await settle(() => fetchStatistics())
expect(fetchMock.mock.calls.length).toBe(calls)
})
})
15 changes: 9 additions & 6 deletions autobot-frontend/src/composables/useErrorMonitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use

const api = useApiClient()
const base = `${getApiBase()}/errors`
/** Dashboard polling fails fast while the errors API is down (#12363). */
const polled = { circuitBreaker: 'error-monitoring' } as const

// ── State ──────────────────────────────────────────────────────────────────
const statistics = ref<ErrorStatistics | null>(null)
Expand Down Expand Up @@ -243,7 +245,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use

async function fetchStatistics(): Promise<void> {
try {
const res = await api.get<DataResponse<ErrorStatistics>>(`${base}/statistics`)
const res = await api.get<DataResponse<ErrorStatistics>>(`${base}/statistics`, polled)
statistics.value = res.data ?? null
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch error statistics'
Expand All @@ -256,6 +258,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use
try {
const res = await api.get<DataResponse<{ errors: RecentError[]; total_count: number }>>(
`${base}/recent?limit=${limit}`,
polled,
)
recentErrors.value = res.data?.errors ?? []
} catch (err) {
Expand All @@ -267,7 +270,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use

async function fetchCategories(): Promise<void> {
try {
const res = await api.get<DataResponse<CategoryBreakdown>>(`${base}/categories`)
const res = await api.get<DataResponse<CategoryBreakdown>>(`${base}/categories`, polled)
categories.value = res.data ?? null
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch error categories'
Expand All @@ -278,7 +281,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use

async function fetchComponents(): Promise<void> {
try {
const res = await api.get<DataResponse<ComponentBreakdown>>(`${base}/components`)
const res = await api.get<DataResponse<ComponentBreakdown>>(`${base}/components`, polled)
components.value = res.data ?? null
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch component errors'
Expand All @@ -291,7 +294,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use

async function fetchSummary(): Promise<void> {
try {
const res = await api.get<DataResponse<MetricsSummary>>(`${base}/metrics/summary`)
const res = await api.get<DataResponse<MetricsSummary>>(`${base}/metrics/summary`, polled)
summary.value = res.data ?? null
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch error metrics summary'
Expand All @@ -304,7 +307,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use
try {
const params = new URLSearchParams({ hours: String(hours) })
if (component) params.set('component', component)
const res = await api.get<DataResponse<TimelineData>>(`${base}/metrics/timeline?${params.toString()}`)
const res = await api.get<DataResponse<TimelineData>>(`${base}/metrics/timeline?${params.toString()}`, polled)
timeline.value = res.data?.timeline ?? []
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch error timeline'
Expand All @@ -315,7 +318,7 @@ export function useErrorMonitoring(options: UseErrorMonitoringOptions = {}): Use

async function fetchTopErrors(limit = defaultTopErrorsLimit): Promise<void> {
try {
const res = await api.get<DataResponse<TopErrorsData>>(`${base}/metrics/top-errors?limit=${limit}`)
const res = await api.get<DataResponse<TopErrorsData>>(`${base}/metrics/top-errors?limit=${limit}`, polled)
topErrors.value = res.data?.top_errors ?? []
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch top errors'
Expand Down
30 changes: 15 additions & 15 deletions autobot-frontend/src/composables/usePrometheusMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export function usePrometheusMetrics(

async function fetchDashboard(): Promise<void> {
try {
dashboard.value = await api.get<DashboardOverview>(`${getApiBase()}/monitoring/dashboard/overview`)
dashboard.value = await api.get<DashboardOverview>(`${getApiBase()}/monitoring/dashboard/overview`, { circuitBreaker: 'monitoring' })
lastUpdate.value = new Date()
error.value = null
} catch (err) {
Expand All @@ -294,7 +294,7 @@ export function usePrometheusMetrics(

async function fetchServices(): Promise<void> {
try {
services.value = await api.get<ServicesSummary>(`${getApiBase()}/monitoring/services/health`)
services.value = await api.get<ServicesSummary>(`${getApiBase()}/monitoring/services/health`, { circuitBreaker: 'monitoring' })
error.value = null
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch services'
Expand All @@ -305,7 +305,7 @@ export function usePrometheusMetrics(

async function fetchAlerts(): Promise<void> {
try {
alerts.value = await api.get<AlertsSummary>(`${getApiBase()}/monitoring/alerts/check`)
alerts.value = await api.get<AlertsSummary>(`${getApiBase()}/monitoring/alerts/check`, { circuitBreaker: 'monitoring' })
error.value = null
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch alerts'
Expand Down Expand Up @@ -461,10 +461,10 @@ export function useSystemMetrics(pollInterval = 10000) {
const { isLoading, wrap } = useLoadingState()
const error = ref<string | null>(null)

async function fetch() {
async function refresh() {
return wrap(async () => {
try {
const data = await api.get<{ metrics?: { system?: SystemMetrics } }>(`${getApiBase()}/monitoring/metrics/current`)
const data = await api.get<{ metrics?: { system?: SystemMetrics } }>(`${getApiBase()}/monitoring/metrics/current`, { circuitBreaker: 'monitoring' })
metrics.value = data.metrics?.system || null
error.value = null
} catch (err) {
Expand All @@ -474,7 +474,7 @@ export function useSystemMetrics(pollInterval = 10000) {
}

const { start: startPolling, stop: stopPolling } = usePollingJob<void>(
async () => { await fetch() },
async () => { await refresh() },
{ intervalMs: pollInterval }
)

Expand All @@ -488,7 +488,7 @@ export function useSystemMetrics(pollInterval = 10000) {
metrics,
isLoading,
error,
fetch,
fetch: refresh,
startPolling: () => startPolling(''),
stopPolling
}
Expand All @@ -506,10 +506,10 @@ export function useServiceHealth(pollInterval = 15000) {
const { isLoading, wrap } = useLoadingState()
const error = ref<string | null>(null)

async function fetch() {
async function refresh() {
return wrap(async () => {
try {
const data = await api.get<ServicesSummary>(`${getApiBase()}/monitoring/services/health`)
const data = await api.get<ServicesSummary>(`${getApiBase()}/monitoring/services/health`, { circuitBreaker: 'monitoring' })
services.value = data.services || []
summary.value = {
total_services: data.total_services,
Expand All @@ -532,7 +532,7 @@ export function useServiceHealth(pollInterval = 15000) {
const overallStatus = computed(() => summary.value?.overall_status ?? 'unknown')

const { start: startPolling, stop: stopPolling } = usePollingJob<void>(
async () => { await fetch() },
async () => { await refresh() },
{ intervalMs: pollInterval }
)

Expand All @@ -551,7 +551,7 @@ export function useServiceHealth(pollInterval = 15000) {
overallStatus,
isLoading,
error,
fetch,
fetch: refresh,
startPolling: () => startPolling(''),
stopPolling
}
Expand Down Expand Up @@ -580,10 +580,10 @@ export function useAlerts(pollInterval = 30000) {
autobot_monitor: 0
})

async function fetch() {
async function refresh() {
return wrap(async () => {
try {
const data = await api.get<AlertsSummary>(`${getApiBase()}/monitoring/alerts/check`)
const data = await api.get<AlertsSummary>(`${getApiBase()}/monitoring/alerts/check`, { circuitBreaker: 'monitoring' })
alerts.value = data.alerts || []
criticalCount.value = data.critical_count
warningCount.value = data.warning_count
Expand All @@ -610,7 +610,7 @@ export function useAlerts(pollInterval = 30000) {
)

const { start: startPolling, stop: stopPolling } = usePollingJob<void>(
async () => { await fetch() },
async () => { await refresh() },
{ intervalMs: pollInterval }
)

Expand All @@ -634,7 +634,7 @@ export function useAlerts(pollInterval = 30000) {
sources, // Issue #474
isLoading,
error,
fetch,
fetch: refresh,
startPolling: () => startPolling(''),
stopPolling
}
Expand Down
2 changes: 1 addition & 1 deletion autobot-frontend/src/composables/useServiceMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function useServiceMessages() {
if (params?.msg_type) sp.append('msg_type', params.msg_type)
const qs = sp.toString()
const url = `${getApiBase()}/service-messages/latest${qs ? `?${qs}` : ''}`
const result = await api.get<LatestMessagesResponse>(url)
const result = await api.get<LatestMessagesResponse>(url, { circuitBreaker: 'service-messages' })
if (result && result.success) {
messages.value = result.messages
}
Expand Down
14 changes: 7 additions & 7 deletions autobot-frontend/src/config/ssot-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export interface TimeoutConfig {
retryDelay: number;
/** WebSocket timeout (ms) */
websocket: number;
/** ApiClient circuit breaker: failures before it opens, then the fail-fast window (ms) */
circuitFailureThreshold: number;
circuitRecoveryMs: number;
}

/**
Expand Down Expand Up @@ -433,6 +436,8 @@ function buildConfig(): AutoBotConfig {
retryAttempts: getEnvNumber('VITE_API_RETRY_ATTEMPTS', 3),
retryDelay: getEnvNumber('VITE_API_RETRY_DELAY', 1000),
websocket: getEnvNumber('VITE_WEBSOCKET_TIMEOUT', 30000),
circuitFailureThreshold: getEnvNumber('VITE_CIRCUIT_FAILURE_THRESHOLD', 3),
circuitRecoveryMs: getEnvNumber('VITE_CIRCUIT_RECOVERY_MS', 30000),
};

// DORMANT: VNC browser path replaced by screenshot panel (#1130). Preserved for #5136 re-integration.
Expand Down Expand Up @@ -739,9 +744,7 @@ export function getBackendWsUrl(): string {
return backendUrl.replace(/^http/, 'ws');
}

/**
* Get API timeout in milliseconds (backward compatibility).
*/
/** Get API timeout in milliseconds (backward compatibility). */
export function getApiTimeout(): number {
return getConfig().timeout.api;
}
Expand Down Expand Up @@ -830,10 +833,7 @@ export async function discoverService(
throw new Error(`Service '${serviceName}' not configured in SLM or environment`);
}

/**
* Clear the service discovery cache.
* Useful when services are known to have changed.
*/
/** Clear the service discovery cache (use when services are known to have changed). */
export function clearDiscoveryCache(): void {
discoveryCache.clear();
}
6 changes: 2 additions & 4 deletions autobot-frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { Role } from '@/types/_generated/workflow'
import { setupAsyncComponentErrorHandler } from '@/utils/asyncComponentHelpers'
import { isChunkLoadError } from '@/utils/chunkLoadError'
import { createLogger } from '@/utils/debugUtils'
import { fetchWithAuth } from '@/utils/fetchWithAuth'
import { getBackendUrl, getSLMAdminUrl } from '@/config/ssot-config'
import { llcCompanyParamGuard } from './llcGuards'

Expand Down Expand Up @@ -1498,10 +1499,7 @@ router.beforeEach(async (to, from) => {
let presetApplied = sessionStorage.getItem('onboarding_preset_applied')
if (presetApplied === null) {
try {
const resp = await fetch(`${getBackendUrl()}/api/onboarding/status`, {
credentials: 'include',
headers: { Accept: 'application/json' },
})
const resp = await fetchWithAuth(`${getBackendUrl()}/api/onboarding/status`, { credentials: 'include', headers: { Accept: 'application/json' } })
if (resp.ok) {
const data = await resp.json()
presetApplied = data?.preset_applied ? '1' : '0'
Expand Down
7 changes: 3 additions & 4 deletions autobot-frontend/src/services/GlobalWebSocketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { ref, reactive, watch, type Ref } from 'vue'
import { DEFAULT_CONFIG } from '@/config/defaults.js'
import { createLogger } from '@/utils/debugUtils'
import { fetchWithAuth } from '@/utils/fetchWithAuth'
import { getApiBase } from '@/config/ssot-config'
import { buildAuthenticatedWsSubprotocols } from '@/utils/buildAuthenticatedWsUrl'
import { useUserStore } from '@/stores/useUserStore'
Expand Down Expand Up @@ -265,15 +266,13 @@ class GlobalWebSocketService {
const timeoutId = setTimeout(() => controller.abort(), 2000)

try {
const response = await fetch(`${getApiBase()}/health`, {
const response = await fetchWithAuth(`${getApiBase()}/health`, {
signal: controller.signal,
cache: 'no-store'
})
clearTimeout(timeoutId)
return response.ok
} catch (error: unknown) {
} finally {
clearTimeout(timeoutId)
throw error
}
}

Expand Down
3 changes: 2 additions & 1 deletion autobot-frontend/src/services/LiveEventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { ref, watch, type Ref } from 'vue'
import { createLogger } from '@/utils/debugUtils'
import { fetchWithAuth } from '@/utils/fetchWithAuth'
import config, { getApiBase } from '@/config/ssot-config'
import { buildAuthenticatedWsSubprotocols } from '@/utils/buildAuthenticatedWsUrl'
import { useUserStore } from '@/stores/useUserStore'
Expand Down Expand Up @@ -394,7 +395,7 @@ class LiveEventService {
*/
async sendSteeringMessage(msg: SteeringMessage): Promise<Record<string, unknown>> {
const url = `${getApiBase()}/agent-terminal/tasks/${encodeURIComponent(msg.task_id)}/steer`
const resp = await fetch(url, {
const resp = await fetchWithAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guidance: msg.guidance, task_id: msg.task_id }),
Expand Down
Loading
Loading