fix: resolve ERR_INSUFFICIENT_RESOURCES connection exhaustion
- Add concurrency limiter (max 4 GET requests) to API layer, leaving slots for SSE and health checks. Write ops bypass the limiter. - Add AbortController to ContainerStats, project detail page, and dashboard to cancel in-flight requests on navigation/unmount. - Move global SSE connection from layout to events page (only consumer). - Add 30s heartbeat to SSE endpoint to detect zombie connections. - Serialize dashboard project fetches to avoid parallel burst. - Rebuild frontend in dev-server.sh so go:embed stays in sync.
This commit is contained in:
+84
-23
@@ -43,7 +43,58 @@ class ApiError extends Error {
|
||||
|
||||
import { getAuthToken, clearAuth } from './auth';
|
||||
|
||||
// ── Concurrency limiter ────────────────────────────────────────────
|
||||
// Chrome allows only 6 concurrent HTTP/1.1 connections per host.
|
||||
// Reserve slots for the persistent SSE stream and health checks by
|
||||
// capping regular API requests to 4 concurrent.
|
||||
const MAX_CONCURRENT = 4;
|
||||
let inflight = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
function acquireSlot(signal?: AbortSignal | null): Promise<void> {
|
||||
if (inflight < MAX_CONCURRENT) {
|
||||
inflight++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const entry = () => { inflight++; resolve(); };
|
||||
queue.push(entry);
|
||||
|
||||
signal?.addEventListener('abort', () => {
|
||||
const idx = queue.indexOf(entry);
|
||||
if (idx !== -1) {
|
||||
queue.splice(idx, 1);
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function releaseSlot(): void {
|
||||
const next = queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
inflight--;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// Write operations (user-initiated) bypass the concurrency limiter
|
||||
// so they are never blocked behind background polling.
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
if (method !== 'GET') {
|
||||
return requestInner<T>(path, init);
|
||||
}
|
||||
await acquireSlot(init?.signal);
|
||||
try {
|
||||
return await requestInner<T>(path, init);
|
||||
} finally {
|
||||
releaseSlot();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestInner<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -82,8 +133,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return envelope.data as T;
|
||||
}
|
||||
|
||||
function get<T>(path: string): Promise<T> {
|
||||
return request<T>(path);
|
||||
function get<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
return request<T>(path, signal ? { signal } : undefined);
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
@@ -106,12 +157,12 @@ function del<T>(path: string): Promise<T> {
|
||||
|
||||
// ── Projects ────────────────────────────────────────────────────────
|
||||
|
||||
export function listProjects(): Promise<Project[]> {
|
||||
return get<Project[]>('/api/projects');
|
||||
export function listProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||
return get<Project[]>('/api/projects', signal);
|
||||
}
|
||||
|
||||
export function getProject(id: string): Promise<ProjectDetail> {
|
||||
return get<ProjectDetail>(`/api/projects/${id}`);
|
||||
export function getProject(id: string, signal?: AbortSignal): Promise<ProjectDetail> {
|
||||
return get<ProjectDetail>(`/api/projects/${id}`, signal);
|
||||
}
|
||||
|
||||
export function createProject(data: Partial<Project>): Promise<Project> {
|
||||
@@ -142,8 +193,8 @@ export function deleteStage(projectId: string, stageId: string): Promise<void> {
|
||||
|
||||
// ── Instances ───────────────────────────────────────────────────────
|
||||
|
||||
export function listInstances(projectId: string, stageId: string): Promise<Instance[]> {
|
||||
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`);
|
||||
export function listInstances(projectId: string, stageId: string, signal?: AbortSignal): Promise<Instance[]> {
|
||||
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`, signal);
|
||||
}
|
||||
|
||||
export function deployInstance(
|
||||
@@ -198,8 +249,8 @@ export function restartInstance(
|
||||
|
||||
// ── Deploys ─────────────────────────────────────────────────────────
|
||||
|
||||
export function listDeploys(limit = 50): Promise<Deploy[]> {
|
||||
return get<Deploy[]>(`/api/deploys?limit=${limit}`);
|
||||
export function listDeploys(limit = 50, signal?: AbortSignal): Promise<Deploy[]> {
|
||||
return get<Deploy[]>(`/api/deploys?limit=${limit}`, signal);
|
||||
}
|
||||
|
||||
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
|
||||
@@ -246,7 +297,9 @@ export function testRegistry(id: string): Promise<{ status: string }> {
|
||||
}
|
||||
|
||||
export function listRegistryTags(registryId: string, image: string): Promise<string[]> {
|
||||
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
|
||||
// Image contains slashes (e.g. "owner/name") — don't encode them;
|
||||
// the backend route uses a wildcard (/tags/*) that expects path segments.
|
||||
return get<string[]>(`/api/registries/${registryId}/tags/${image}`);
|
||||
}
|
||||
|
||||
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
|
||||
@@ -255,8 +308,8 @@ export function listRegistryImages(registryId: string): Promise<RegistryImage[]>
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────
|
||||
|
||||
export function getSettings(): Promise<Settings> {
|
||||
return get<Settings>('/api/settings');
|
||||
export function getSettings(signal?: AbortSignal): Promise<Settings> {
|
||||
return get<Settings>('/api/settings', signal);
|
||||
}
|
||||
|
||||
export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
||||
@@ -285,14 +338,14 @@ export function fetchContainerLogs(
|
||||
return get<string[]>(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`);
|
||||
}
|
||||
|
||||
export function listProjectImages(projectId: string): Promise<LocalImage[]> {
|
||||
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
|
||||
export function listProjectImages(projectId: string, signal?: AbortSignal): Promise<LocalImage[]> {
|
||||
return get<LocalImage[]>(`/api/projects/${projectId}/images`, signal);
|
||||
}
|
||||
|
||||
export function getUnusedImageStats(): Promise<{
|
||||
export function getUnusedImageStats(signal?: AbortSignal): Promise<{
|
||||
total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean;
|
||||
}> {
|
||||
return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images');
|
||||
return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images', signal);
|
||||
}
|
||||
|
||||
export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> {
|
||||
@@ -580,8 +633,8 @@ export function clearAllEvents(): Promise<{ status: string; count: number }> {
|
||||
|
||||
// ── Stale Containers ────────────────────────────────────────────────
|
||||
|
||||
export function fetchStaleContainers(): Promise<StaleContainer[]> {
|
||||
return get<StaleContainer[]>('/api/containers/stale');
|
||||
export function fetchStaleContainers(signal?: AbortSignal): Promise<StaleContainer[]> {
|
||||
return get<StaleContainer[]>('/api/containers/stale', signal);
|
||||
}
|
||||
|
||||
export function cleanupStaleContainer(id: string): Promise<{ deleted: string }> {
|
||||
@@ -597,10 +650,12 @@ export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> {
|
||||
export function fetchContainerStats(
|
||||
projectId: string,
|
||||
stageId: string,
|
||||
instanceId: string
|
||||
instanceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ContainerStats> {
|
||||
return get<ContainerStats>(
|
||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`
|
||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
@@ -608,8 +663,8 @@ export function fetchContainerStats(
|
||||
|
||||
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
|
||||
|
||||
export function listStaticSites(): Promise<StaticSite[]> {
|
||||
return get<StaticSite[]>('/api/sites');
|
||||
export function listStaticSites(signal?: AbortSignal): Promise<StaticSite[]> {
|
||||
return get<StaticSite[]>('/api/sites', signal);
|
||||
}
|
||||
|
||||
export function getStaticSite(id: string): Promise<StaticSite> {
|
||||
@@ -710,4 +765,10 @@ export function deleteStaticSiteSecret(
|
||||
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
|
||||
}
|
||||
|
||||
export function getStaticSiteStorage(
|
||||
siteId: string
|
||||
): Promise<import('./types').StaticSiteStorageUsage> {
|
||||
return get<import('./types').StaticSiteStorageUsage>(`/api/sites/${siteId}/storage`);
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
|
||||
Reference in New Issue
Block a user