Files
pikasTech-unidesk/scripts/src/platform-infra-gitea-caddy.ts
T
2026-07-11 11:12:39 +02:00

98 lines
3.8 KiB
TypeScript

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<GiteaWebhookIngressRetry, "maxRetries" | "tryIntervalMs" | "dialTimeoutMs" | "writeTimeoutMs">,
): 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<GiteaWebhookIngressRetry, "maxRetries" | "tryIntervalMs">,
): 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<GiteaWebhookIngressRetry, "maxRetries" | "tryIntervalMs" | "dialTimeoutMs" | "writeTimeoutMs">,
): 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
}
}
}`;
}