fix(proxmox): vorübergehende Störungen wiederholen statt abbrechen
Der Feldtest auf einem ausgelasteten pve-demo scheiterte an Verbindungswacklern, nicht an Proxmox: pveproxy meldete „Broken pipe“ (Backend weg), schloss Keep-Alive-Verbindungen, Storage-Abfragen dauerten 15 s. GET wird bis zu dreimal wiederholt, Schreibendes nur bei sicher nicht angekommener Anfrage; Schreibaufrufe dürfen 120 s dauern, Keep-Alive 2 s. Refs #020 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ export type Aufruf = { methode: Methode; pfad: string; parameter?: Parameter };
|
||||
|
||||
export type AufrufBeobachter = (
|
||||
aufruf: Aufruf,
|
||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler; wiederholung?: number },
|
||||
) => void;
|
||||
|
||||
/**
|
||||
@@ -38,6 +38,41 @@ export function istTrockenUpid(upid: unknown): boolean {
|
||||
}
|
||||
|
||||
const STANDARD_ZEITLIMIT_MS = 30_000;
|
||||
/**
|
||||
* Schreibende Aufrufe dürfen länger dauern: ein zu früh abgebrochenes „VM anlegen“ läuft auf dem
|
||||
* Node womöglich trotzdem – ein zweiter Versuch wäre dann doppelt.
|
||||
*/
|
||||
const SCHREIB_ZEITLIMIT_MS = 120_000;
|
||||
/** Versuche je Aufruf bei vorübergehenden Störungen (nur, wo ein Wiederholen sicher ist). */
|
||||
const VERSUCHE = 3;
|
||||
|
||||
/**
|
||||
* Ob eine Störung vorübergehend ist – und ob die Anfrage den Node womöglich erreicht hat.
|
||||
* Ausgelastete Nodes liefern davon reichlich: pveproxy verliert die Verbindung zu pvedaemon
|
||||
* („Broken pipe“, 595/596), schließt Keep-Alive-Verbindungen, Abfragen laufen ins Zeitlimit.
|
||||
*/
|
||||
export function stoerung(fehler: unknown): "nicht_angekommen" | "unklar" | null {
|
||||
if (fehler instanceof ProxmoxFehler) {
|
||||
const status = fehler.details.status;
|
||||
if (status === 595 || status === 596) return "unklar";
|
||||
if (
|
||||
(status === 500 || status === 502 || status === 503) &&
|
||||
/broken pipe|connection (refused|reset|closed)|got timeout|timed out/i.test(fehler.ursache)
|
||||
)
|
||||
return "unklar";
|
||||
return null;
|
||||
}
|
||||
let ursache: unknown = fehler;
|
||||
for (let i = 0; i < 3 && ursache instanceof Error && ursache.cause; i += 1) ursache = ursache.cause;
|
||||
const code = (ursache as { code?: string } | undefined)?.code ?? "";
|
||||
if (code === "ECONNREFUSED") return "nicht_angekommen";
|
||||
if (
|
||||
["ECONNRESET", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "ETIMEDOUT"].includes(code) ||
|
||||
(ursache instanceof Error && (ursache.name === "TimeoutError" || ursache.name === "AbortError"))
|
||||
)
|
||||
return "unklar";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||
export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams {
|
||||
@@ -75,6 +110,8 @@ export class ProxmoxClient {
|
||||
zeitlimitMs?: number;
|
||||
beobachter?: AufrufBeobachter;
|
||||
trocken?: boolean;
|
||||
/** Pause vor dem n-ten Wiederholen (Tests setzen 0). */
|
||||
pauseMs?: (versuch: number) => number;
|
||||
} = {},
|
||||
) {
|
||||
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||
@@ -85,7 +122,7 @@ export class ProxmoxClient {
|
||||
);
|
||||
}
|
||||
this.basis = verbindung.url.replace(/\/+$/, "") + "/api2/json";
|
||||
this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 10_000 });
|
||||
this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 2_000 });
|
||||
this.kopf = `PVEAPIToken=${verbindung.tokenId}=${verbindung.tokenGeheimnis}`;
|
||||
}
|
||||
|
||||
@@ -120,15 +157,30 @@ export class ProxmoxClient {
|
||||
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
|
||||
}
|
||||
const beginn = Date.now();
|
||||
try {
|
||||
const ergebnis = await this.senden<T>(aufruf);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
||||
return ergebnis;
|
||||
} catch (fehler) {
|
||||
const proxmoxFehler =
|
||||
fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler });
|
||||
throw proxmoxFehler;
|
||||
for (let versuch = 1; ; versuch += 1) {
|
||||
try {
|
||||
const ergebnis = await this.senden<T>(aufruf);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
||||
return ergebnis;
|
||||
} catch (fehler) {
|
||||
// GET darf immer noch einmal; Schreibendes nur, wenn die Anfrage sicher nie ankam.
|
||||
const art = stoerung(fehler);
|
||||
const sicher = art === "nicht_angekommen" || (art === "unklar" && aufruf.methode === "GET");
|
||||
if (sicher && versuch < VERSUCHE) {
|
||||
const pause = this.optionen.pauseMs?.(versuch) ?? (versuch === 1 ? 1000 : 3000);
|
||||
this.optionen.beobachter?.(aufruf, {
|
||||
dauerMs: Date.now() - beginn,
|
||||
fehler: fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url),
|
||||
wiederholung: versuch,
|
||||
});
|
||||
await new Promise((fertig) => setTimeout(fertig, pause));
|
||||
continue;
|
||||
}
|
||||
const proxmoxFehler =
|
||||
fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler });
|
||||
throw proxmoxFehler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +198,10 @@ export class ProxmoxClient {
|
||||
...(mitRumpf ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
||||
},
|
||||
body: mitRumpf ? kodiert.toString() : undefined,
|
||||
signal: AbortSignal.timeout(this.optionen.zeitlimitMs ?? STANDARD_ZEITLIMIT_MS),
|
||||
signal: AbortSignal.timeout(
|
||||
this.optionen.zeitlimitMs ??
|
||||
(aufruf.methode === "GET" ? STANDARD_ZEITLIMIT_MS : SCHREIB_ZEITLIMIT_MS),
|
||||
),
|
||||
});
|
||||
|
||||
const text = await antwort.text();
|
||||
|
||||
@@ -14,7 +14,9 @@ export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter {
|
||||
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
|
||||
void senke.log(
|
||||
ergebnis.fehler ? "warn" : "debug",
|
||||
`${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}`,
|
||||
`${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}${
|
||||
ergebnis.wiederholung ? ` · vorübergehend, Versuch ${ergebnis.wiederholung + 1} folgt` : ""
|
||||
}`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user