export interface GiteaWebhookIngressRetry { enabled: boolean; maxBodyBytes: number; maxRetries: number; tryIntervalMs: number; dialTimeoutMs: number; writeTimeoutMs: number; retryStatusCodes: number[]; } export interface GiteaWebhookCaddyRoute { publicPath: string; remotePort: number; } export function validateGiteaWebhookTiming( responseBudgetMs: number, ingressRetry: Pick, ): void { if (responseBudgetMs >= 9000) { throw new Error("responseBudgetMs must stay below 9000 to leave margin under GitHub's 10 second webhook deadline"); } if (ingressRetry.maxRetries !== 1) { throw new Error("ingressRetry.maxRetries must be 1 so the fsync acceptance path has one bounded replay"); } const responseHeaderTimeoutMs = deriveGiteaWebhookResponseHeaderTimeoutMs(responseBudgetMs, ingressRetry); const hardBoundMs = (ingressRetry.dialTimeoutMs + ingressRetry.writeTimeoutMs + responseHeaderTimeoutMs) * (ingressRetry.maxRetries + 1) + ingressRetry.tryIntervalMs * ingressRetry.maxRetries; if (hardBoundMs > responseBudgetMs) { throw new Error("Caddy retry attempts and intervals must fit inside responseBudgetMs"); } } export function deriveGiteaWebhookAttemptBudgetMs( responseBudgetMs: number, ingressRetry: Pick, ): number { const attempts = ingressRetry.maxRetries + 1; const intervalBudgetMs = ingressRetry.maxRetries * ingressRetry.tryIntervalMs; const attemptBudgetMs = Math.floor((responseBudgetMs - intervalBudgetMs) / attempts); if (!Number.isFinite(attemptBudgetMs) || attemptBudgetMs < 1) { throw new Error("responseBudgetMs must leave a positive response-header timeout for every Caddy attempt"); } return attemptBudgetMs; } export function deriveGiteaWebhookResponseHeaderTimeoutMs( responseBudgetMs: number, ingressRetry: Pick, ): number { const attemptBudgetMs = deriveGiteaWebhookAttemptBudgetMs(responseBudgetMs, ingressRetry); const responseHeaderTimeoutMs = attemptBudgetMs - ingressRetry.dialTimeoutMs - ingressRetry.writeTimeoutMs; if (!Number.isFinite(responseHeaderTimeoutMs) || responseHeaderTimeoutMs < 1) { throw new Error("dialTimeoutMs and writeTimeoutMs must leave a positive response-header timeout in every Caddy attempt"); } return responseHeaderTimeoutMs; } export function renderGiteaWebhookCaddyHandle( route: GiteaWebhookCaddyRoute, retry: GiteaWebhookIngressRetry, responseBudgetMs: number, legacyResponseHeaderTimeoutSeconds: number, ): string { if (!retry.enabled) { return ` handle ${route.publicPath}* { reverse_proxy 127.0.0.1:${route.remotePort} { transport http { response_header_timeout ${legacyResponseHeaderTimeoutSeconds}s } } }`; } validateGiteaWebhookTiming(responseBudgetMs, retry); const responseHeaderTimeoutMs = deriveGiteaWebhookResponseHeaderTimeoutMs(responseBudgetMs, retry); const replayBufferBytes = retry.maxBodyBytes + 1; const retryStatuses = retry.retryStatusCodes.join(", "); return ` handle ${route.publicPath}* { request_body { max_size ${retry.maxBodyBytes} } reverse_proxy 127.0.0.1:${route.remotePort} { request_buffers ${replayBufferBytes} lb_retries ${retry.maxRetries} lb_try_duration ${responseBudgetMs}ms lb_try_interval ${retry.tryIntervalMs}ms lb_retry_match { expression \`method('POST') && ({rp.is_transport_error} || {rp.status_code} in [${retryStatuses}])\` } transport http { dial_timeout ${retry.dialTimeoutMs}ms write_timeout ${retry.writeTimeoutMs}ms response_header_timeout ${responseHeaderTimeoutMs}ms } } }`; }