Files
nebula/src/lib/git/http.ts
T
florian.wilde 76c064bd31
CI / pruefen (pull_request) Canceled after 0s
fix(git): haengengebliebene Teil-Abrufe einmal nachschlagen
Behebt florian.wilde/nebula#23.

Beobachtet an der laufenden Instanz: zwei von acht Teil-Abrufen liefen in
eine Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe von aussen
einzeln in 80 ms antworteten. Nicht die Endpunkte sind langsam - die
Achterrunde erwuergt sich gelegentlich selbst.

Daraus folgt die Maßnahme, und zwar genau diese: was haengen bleibt,
bekommt einen zweiten Versuch, und der laeuft NICHT wieder im Schwung
mit. Die Nachschlaege haengen an einer Kette und gehen nacheinander
hinaus.

Die Aufrufstellen in den drei Clients bleiben unberuehrt - hol() liefert
weiterhin den endgueltigen Wert. Das war der Grund, es in Teilabrufe zu
loesen statt hinter dem Promise.all: dort haette jeder Client acht
Variablen neu zuweisen muessen.

ZWEI GRENZEN, BEIDE ABSICHTLICH

Nachgeschlagen wird nur bei 408 und bei 0 - Zeitueberschreitung und eine
Verbindung, die gar nicht zustande kam. Ein 403 wird durch Wiederholen
nicht besser, ein 429 durch sofortiges Wiederholen schlimmer, ein 404
bleibt ein 404.

Und hoechstens zweimal je Abgleich. Acht Zeitueberschreitungen zu je 15
Sekunden waeren zwei Minuten, in denen "Jetzt synchronisieren" wie ein
Absturz aussieht. Wer weg ist, soll schnell als weg erkannt werden.

Klappt auch der zweite Versuch nicht, steht das am Repository - mit dem
Vermerk "(auch im zweiten Versuch)", damit erkennbar bleibt, dass es kein
Zufall war.

NICHT GEMACHT: die acht Aufrufe entzerren. Das waere eine Vermutung ueber
die Ursache. Bleibt das Problem, sagt der Vermerk genau das, und dann ist
die Entzerrung dran.

AM RANDE BEHOBEN

Den beiden afterEach-Bloecken in http.test.ts fehlte restoreAllMocks.
Die Spies zaehlten ueber Tests hinweg weiter; aufgefallen, weil die neuen
Pruefungen an Aufrufzahlen haengen und statt 2 gleich 10 sahen.

Gegengeprobt: mit abgeschaltetem Nachschlag fallen vier der sechs neuen
Tests - die beiden, die das Ausbleiben pruefen, bleiben gruen.

Refs #23
2026-09-09 22:46:09 +02:00

361 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { GitApiError } from "./types";
const TIMEOUT_VORGABE = 15000;
/**
* Zeitlimit je Aufruf.
*
* Bewusst mit Pruefung: Number("") ist 0 und Number("abc") ist NaN – beides
* laesst setTimeout sofort ausloesen, und dann bricht jeder ausgehende
* Aufruf ab, bevor er begonnen hat. Eine verrutschte Zeile in der .env
* legte damit den ganzen Abgleich still, ohne dass irgendwo etwas stand.
*/
export function zeitlimit(
roh: string | undefined,
warnen: (text: string) => void = console.warn,
): number {
const wert = roh?.trim();
if (!wert) return TIMEOUT_VORGABE;
const zahl = Number(wert);
// Untergrenze 1000 ms: darunter ist kein Aufruf ueber das Netz zu
// schaffen, und wer das eintraegt, hat sich vertan.
if (!Number.isFinite(zahl) || zahl < 1000) {
warnen(
`[nebula] GIT_API_TIMEOUT_MS="${wert}" ist unbrauchbar (Zahl ab 1000 erwartet). ` +
`Es gelten ${TIMEOUT_VORGABE} ms.`,
);
return TIMEOUT_VORGABE;
}
return zahl;
}
const DEFAULT_TIMEOUT = zeitlimit(process.env.GIT_API_TIMEOUT_MS);
export interface FetchOptions {
headers?: Record<string, string>;
timeoutMs?: number;
/** Wenn true, liefert die Funktion bei 404 null statt zu werfen. */
allow404?: boolean;
}
/** Entfernt einen abschliessenden Slash, damit URLs sauber zusammengesetzt werden. */
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
function friendlyMessage(status: number, url: string, body: string): string {
switch (status) {
case 401:
return "Nicht autorisiert (401) – Token fehlt, ist abgelaufen oder ungueltig.";
case 403:
return body.toLowerCase().includes("rate limit")
? "Rate-Limit erreicht (403) – bitte spaeter erneut versuchen oder Token hinterlegen."
: "Zugriff verweigert (403) – Token hat nicht die noetigen Scopes.";
case 404:
return "Nicht gefunden (404) – Repo-Pfad pruefen oder Token hat keinen Lesezugriff.";
case 429:
return "Zu viele Anfragen (429) – Rate-Limit des Git-Systems.";
default:
if (status >= 500) return `Serverfehler ${status} beim Git-System.`;
return `Anfrage fehlgeschlagen (${status}) auf ${url}`;
}
}
/** GET mit Timeout, JSON-Parsing und sprechenden Fehlern. */
export async function getJson<T>(
url: string,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
// Vor jedem ausgehenden Aufruf pruefen, wohin er geht. Die Basis-URL
// stammt aus einem Formular, also aus Benutzereingabe.
await assertAllowedUrl(url);
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
signal: controller.signal,
cache: "no-store",
redirect: "follow",
});
// Ein Redirect koennte auf eine interne Adresse zeigen (etwa bei einem
// umbenannten GitHub-Repo, aber eben auch boesartig). Deshalb das
// tatsaechliche Ziel nachtraeglich noch einmal pruefen.
if (response.redirected && response.url && response.url !== url) {
await assertAllowedUrl(response.url);
}
if (response.status === 404 && options.allow404) {
return null as T;
}
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new GitApiError(
friendlyMessage(response.status, url, body),
response.status,
url,
);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/**
* Schreibender Aufruf – POST, PATCH oder PUT.
*
* Bewusst dieselbe Schleuse wie getJson: die Basis-URL stammt aus einem
* Formular, und ohne die Pruefung waere ein schreibender Aufruf der weitaus
* unangenehmere Weg nach innen. Redirects werden hier **nicht** verfolgt.
* Ein 307 auf eine interne Adresse wuerde den Rumpf mitnehmen, und der
* enthaelt bei uns immer den Token im Header – lieber ein Fehler als eine
* Anfrage, die woanders landet.
*/
export async function sendJson<T>(
url: string,
method: "POST" | "PATCH" | "PUT",
body: unknown,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
await assertAllowedUrl(url);
const response = await fetch(url, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
body: JSON.stringify(body),
signal: controller.signal,
cache: "no-store",
redirect: "manual",
});
// Bei redirect: "manual" kommt der Umleitungsstatus durch, statt dass
// fetch ihm folgt. Das ist hier gewollt und ein Fehlerfall.
if (response.status >= 300 && response.status < 400) {
throw new GitApiError(
"Das Git-System hat umgeleitet. Schreibende Aufrufe folgen dem nicht – " +
"bitte die Basis-URL der Verbindung auf die endgueltige Adresse setzen.",
response.status,
url,
);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new GitApiError(
friendlyMessage(response.status, url, text),
response.status,
url,
);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/**
* Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck.
*
* Nur fuer Abrufe benutzen, bei denen ein Fehlschlag wirklich nichts
* bedeutet. Fuer die Listen eines Snapshots ist das falsch – dort ist
* "nicht geholt" etwas anderes als "nichts vorhanden". Dafuer gibt es
* Teilabrufe.
*/
export async function getJsonSafe<T>(
url: string,
fallback: T,
options: FetchOptions = {},
): Promise<T> {
try {
const result = await getJson<T>(url, options);
return (result ?? fallback) as T;
} catch {
return fallback;
}
}
/**
* Wie viele zweite Versuche ein Abgleich hoechstens unternimmt.
*
* Zwei, nicht acht: jede Zeitueberschreitung kostet volle 15 Sekunden, und
* acht davon hintereinander waeren zwei Minuten, in denen "Jetzt
* synchronisieren" wie ein Absturz aussieht. Wer wirklich weg ist, soll
* schnell als weg erkannt werden.
*/
const NACHSCHLAG_MAX = 2;
/** Warum ein einzelner Teil-Abruf nicht geklappt hat. */
export type Teilfehler = Record<string, string>;
/**
* Sammelstelle fuer die Listen eines Snapshots.
*
* Der Vorgaenger dieser Klasse war ein getJsonSafe je Liste. Das hat jeden
* Fehler in eine leere Liste verwandelt, und damit war ein 403 nicht mehr
* von einem Repository ohne Branches zu unterscheiden: Der Abgleich meldete
* "Snapshot aktualisiert: 0 Branches, 0 Tags", schrieb Nullen ueber den
* letzten guten Stand und niemand konnte sehen, woran es lag.
*
* Der Fallback bleibt – ein einzelner Fehlschlag darf den ganzen Abgleich
* nicht abbrechen –, aber der Grund wird festgehalten.
*/
export class Teilabrufe {
readonly fehler: Teilfehler = {};
/**
* Nachschlaege haengen aneinander, damit sie nacheinander laufen.
*
* Die Runde selbst ist gleichzeitig – acht Aufrufe auf einmal. Genau das
* scheint sie gelegentlich zu erwuergen: beobachtet wurden zwei von acht
* mit Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe einzeln in
* 80 ms antworteten. Der zweite Versuch soll deshalb nicht wieder in
* denselben Schwung geraten.
*/
private kette: Promise<unknown> = Promise.resolve();
private nachgeschlagen = 0;
async hol<T>(
feld: string,
url: string,
fallback: T,
options: FetchOptions = {},
): Promise<T> {
try {
const result = await getJson<T>(url, options);
return (result ?? fallback) as T;
} catch (error) {
if (this.lohntNachschlag(error)) {
const zweiter = await this.nachschlag<T>(url, options);
if (zweiter.ok) return (zweiter.wert ?? fallback) as T;
this.fehler[feld] = `${beschreibe(zweiter.fehler)} (auch im zweiten Versuch)`;
return fallback;
}
this.fehler[feld] = beschreibe(error);
return fallback;
}
}
/**
* Ein zweiter Versuch hilft nur bei Fehlern, die nichts ueber die Sache
* aussagen: Zeitueberschreitung (408) und eine Verbindung, die gar nicht
* zustande kam (0). Ein 403 wird durch Wiederholen nicht besser, ein 429
* durch sofortiges Wiederholen schlimmer, und ein 404 bleibt ein 404.
*/
private lohntNachschlag(error: unknown): boolean {
if (this.nachgeschlagen >= NACHSCHLAG_MAX) return false;
if (!(error instanceof GitApiError)) return false;
return error.status === 408 || error.status === 0;
}
private async nachschlag<T>(
url: string,
options: FetchOptions,
): Promise<{ ok: boolean; wert?: T; fehler?: unknown }> {
this.nachgeschlagen += 1;
const versuch = this.kette.then(async () => getJson<T>(url, options));
// Auch ein Fehlschlag darf die Kette nicht abreissen lassen, sonst
// scheitern die folgenden Nachschlaege gleich mit.
this.kette = versuch.catch(() => undefined);
try {
return { ok: true, wert: await versuch };
} catch (fehler) {
return { ok: false, fehler };
}
}
/** true, sobald mindestens ein Teil-Abruf gescheitert ist. */
get unvollstaendig(): boolean {
return Object.keys(this.fehler).length > 0;
}
}
/**
* Kurzfassung eines Fehlers, wie sie am Repository angezeigt wird.
*
* getJson gibt ausschliesslich GitApiError heraus – ein blockiertes Ziel
* kommt als 403, eine Zeitueberschreitung als 408, ein nicht erreichter
* Host als 0. Der Status ist damit die eigentliche Auskunft, und die
* uebrigen Zweige sind nur fuer den Fall da, dass sich das einmal aendert.
*/
function beschreibe(error: unknown): string {
if (error instanceof GitApiError) {
return error.status
? `HTTP ${error.status}: ${error.message}`
: error.message;
}
if (error instanceof Error) return `${error.name}: ${error.message}`;
return String(error);
}
export function toIso(value: unknown): string | null {
if (!value) return null;
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
export function shortSha(sha: string | null | undefined): string {
return (sha ?? "").slice(0, 7);
}