/** * Production-Grade Dokploy API Client * * Features: * - Correct API parameter usage (environmentId, not projectId) * - Retry logic with exponential backoff * - Circuit breaker pattern * - Comprehensive error handling * - Idempotency checks * - Structured logging * - Rollback mechanisms */ interface DokployProject { projectId: string; name: string; description?: string; createdAt: string; organizationId: string; env: string; } interface DokployEnvironment { environmentId: string; name: string; projectId: string; isDefault: boolean; env?: string; createdAt: string; } interface DokployApplication { applicationId: string; name: string; environmentId: string; applicationStatus: 'idle' | 'running' | 'done' | 'error'; dockerImage?: string; sourceType?: 'docker' | 'git' | 'github'; createdAt: string; } interface DokployDomain { domainId: string; host: string; applicationId: string; https: boolean; port: number; } interface RetryConfig { maxRetries: number; initialDelay: number; maxDelay: number; multiplier: number; } interface CircuitBreakerConfig { threshold: number; timeout: number; halfOpenAttempts: number; } interface LogEntry { timestamp: string; level: 'info' | 'warn' | 'error'; phase: string; action: string; message: string; duration_ms?: number; error?: { code: string; message: string; apiResponse?: unknown; }; } class CircuitBreaker { private failures = 0; private lastFailureTime: number | null = null; private state: 'closed' | 'open' | 'half-open' = 'closed'; constructor(private config: CircuitBreakerConfig) {} async execute(fn: () => Promise): Promise { if (this.state === 'open') { const now = Date.now(); if (this.lastFailureTime && now - this.lastFailureTime < this.config.timeout) { throw new Error('Circuit breaker is OPEN - too many failures'); } this.state = 'half-open'; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private onSuccess() { this.failures = 0; this.state = 'closed'; } private onFailure() { this.failures++; this.lastFailureTime = Date.now(); if (this.failures >= this.config.threshold) { this.state = 'open'; } } getState() { return this.state; } } export class DokployProductionClient { private baseUrl: string; private apiToken: string; private retryConfig: RetryConfig; private circuitBreaker: CircuitBreaker; private logs: LogEntry[] = []; constructor( baseUrl: string, apiToken: string, retryConfig?: Partial, circuitBreakerConfig?: Partial ) { if (!baseUrl) throw new Error('DOKPLOY_URL is required'); if (!apiToken) throw new Error('DOKPLOY_API_TOKEN is required'); this.baseUrl = baseUrl.replace(/\/$/, ''); this.apiToken = apiToken; this.retryConfig = { maxRetries: retryConfig?.maxRetries ?? 5, initialDelay: retryConfig?.initialDelay ?? 1000, maxDelay: retryConfig?.maxDelay ?? 16000, multiplier: retryConfig?.multiplier ?? 2, }; this.circuitBreaker = new CircuitBreaker({ threshold: circuitBreakerConfig?.threshold ?? 5, timeout: circuitBreakerConfig?.timeout ?? 60000, halfOpenAttempts: circuitBreakerConfig?.halfOpenAttempts ?? 3, }); } private log(entry: Omit) { const logEntry: LogEntry = { ...entry, timestamp: new Date().toISOString(), }; this.logs.push(logEntry); // Output to console for monitoring const level = entry.level.toUpperCase(); const msg = `[${level}] ${entry.phase}/${entry.action}: ${entry.message}`; if (entry.level === 'error') { console.error(msg, entry.error); } else { console.log(msg); } } getLogs(): LogEntry[] { return [...this.logs]; } private async sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } private isRetryableError(error: unknown, statusCode?: number): boolean { // Retry on network errors and 5xx server errors if (statusCode && statusCode >= 500) return true; if (statusCode === 429) return true; // Rate limiting if (error instanceof TypeError && error.message.includes('fetch')) return true; return false; } private async request( method: string, endpoint: string, body?: unknown, phase = 'api', action = 'request' ): Promise { const url = `${this.baseUrl}/api${endpoint}`; const startTime = Date.now(); return this.circuitBreaker.execute(async () => { let lastError: Error | null = null; let delay = this.retryConfig.initialDelay; for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) { try { const response = await fetch(url, { method, headers: { 'x-api-key': this.apiToken, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, }); const text = await response.text(); if (!response.ok) { let errorMessage = `API error (${response.status})`; let apiResponse: unknown; try { apiResponse = JSON.parse(text); errorMessage = (apiResponse as { message?: string }).message || errorMessage; } catch { errorMessage = text || errorMessage; } // Don't retry 4xx errors (except 429) if (response.status >= 400 && response.status < 500 && response.status !== 429) { const duration = Date.now() - startTime; this.log({ level: 'error', phase, action, message: `Request failed: ${errorMessage}`, duration_ms: duration, error: { code: `HTTP_${response.status}`, message: errorMessage, apiResponse, }, }); throw new Error(errorMessage); } // Retry 5xx and 429 if (attempt < this.retryConfig.maxRetries && this.isRetryableError(null, response.status)) { this.log({ level: 'warn', phase, action, message: `Retrying after ${response.status} (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`, }); await this.sleep(delay); delay = Math.min(delay * this.retryConfig.multiplier, this.retryConfig.maxDelay); continue; } throw new Error(errorMessage); } const duration = Date.now() - startTime; this.log({ level: 'info', phase, action, message: 'Request successful', duration_ms: duration, }); return text ? (JSON.parse(text) as T) : ({} as T); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < this.retryConfig.maxRetries && this.isRetryableError(error)) { this.log({ level: 'warn', phase, action, message: `Retrying after error (attempt ${attempt + 1}/${this.retryConfig.maxRetries}): ${lastError.message}`, }); await this.sleep(delay); delay = Math.min(delay * this.retryConfig.multiplier, this.retryConfig.maxDelay); continue; } const duration = Date.now() - startTime; this.log({ level: 'error', phase, action, message: `Request failed: ${lastError.message}`, duration_ms: duration, error: { code: 'REQUEST_FAILED', message: lastError.message, }, }); throw lastError; } } throw lastError || new Error('Request failed after all retries'); }); } // ==================== PRODUCTION API METHODS ==================== async createProject( name: string, description?: string ): Promise<{ project: DokployProject; environment: DokployEnvironment }> { const response = await this.request<{ project: DokployProject; environment: DokployEnvironment }>( 'POST', '/project.create', { name, description }, 'project', 'create' ); // API returns both project and default environment in one call return response; } async getProjects(): Promise { return this.request('GET', '/project.all', undefined, 'project', 'list'); } async findProjectByName(name: string): Promise<{ project: DokployProject; environmentId?: string } | null> { const projects = await this.getProjects(); const project = projects.find(p => p.name === name); if (!project) return null; // Try to get environment ID for this project try { const env = await this.getDefaultEnvironment(project.projectId); return { project, environmentId: env.environmentId }; } catch { // If we can't get environment, just return project return { project }; } } async getEnvironmentsByProjectId(projectId: string): Promise { return this.request( 'GET', `/environment.byProjectId?projectId=${projectId}`, undefined, 'environment', 'query' ); } async getDefaultEnvironment(projectId: string): Promise { const environments = await this.getEnvironmentsByProjectId(projectId); const defaultEnv = environments.find(e => e.isDefault); if (!defaultEnv) { throw new Error(`No default environment found for project ${projectId}`); } return defaultEnv; } async createApplication(name: string, environmentId: string): Promise { return this.request( 'POST', '/application.create', { name, environmentId }, 'application', 'create' ); } async updateApplication( applicationId: string, updates: { dockerImage?: string; sourceType?: 'docker' | 'git' | 'github'; [key: string]: unknown; } ): Promise { return this.request( 'POST', '/application.update', { applicationId, ...updates }, 'application', 'update' ); } async setApplicationEnv(applicationId: string, env: string): Promise { await this.request( 'POST', '/application.update', { applicationId, env }, 'application', 'set-env' ); } async getApplication(applicationId: string): Promise { return this.request( 'GET', `/application.one?applicationId=${applicationId}`, undefined, 'application', 'get' ); } async createDomain( host: string, applicationId: string, https = true, port = 8080 ): Promise { return this.request( 'POST', '/domain.create', { host, applicationId, https, port }, 'domain', 'create' ); } async deployApplication(applicationId: string): Promise { await this.request( 'POST', '/application.deploy', { applicationId }, 'deploy', 'trigger' ); } async deleteApplication(applicationId: string): Promise { await this.request( 'POST', '/application.delete', { applicationId }, 'application', 'delete' ); } async deleteProject(projectId: string): Promise { await this.request( 'POST', '/project.remove', { projectId }, 'project', 'delete' ); } async deleteDomain(domainId: string): Promise { await this.request( 'POST', '/domain.delete', { domainId }, 'domain', 'delete' ); } async createMount( applicationId: string, volumeName: string, mountPath: string ): Promise<{ mountId: string }> { return this.request<{ mountId: string }>( 'POST', '/mounts.create', { type: 'volume', volumeName, mountPath, serviceId: applicationId, serviceType: 'application', }, 'mount', 'create' ); } getCircuitBreakerState() { return this.circuitBreaker.getState(); } } export function createProductionDokployClient(): DokployProductionClient { const baseUrl = process.env.DOKPLOY_URL; const apiToken = process.env.DOKPLOY_API_TOKEN; if (!baseUrl) { throw new Error('DOKPLOY_URL environment variable is not set'); } if (!apiToken) { throw new Error('DOKPLOY_API_TOKEN environment variable is not set'); } return new DokployProductionClient(baseUrl, apiToken); }