Files
nebula/src/lib/git/http.test.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

266 lines
9.3 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Teilabrufe, zeitlimit } from "./http";
/**
* Diese beiden Stellen haben zusammen einen Ausfall verursacht, den niemand
* sehen konnte: Der Abgleich holte die Listen eines Repositories, verlor sie
* unterwegs, verwandelte jeden Fehler in eine leere Liste und meldete
* "Snapshot aktualisiert: 0 Branches, 0 Tags". Aus der Oberflaeche war nicht
* zu erkennen, dass ueberhaupt etwas schiefgegangen war.
*/
describe("zeitlimit", () => {
it("nimmt einen brauchbaren Wert", () => {
expect(zeitlimit("30000")).toBe(30000);
expect(zeitlimit(" 20000 ")).toBe(20000);
});
it("faellt ohne Angabe auf die Vorgabe zurueck", () => {
expect(zeitlimit(undefined)).toBe(15000);
expect(zeitlimit("")).toBe(15000);
expect(zeitlimit(" ")).toBe(15000);
});
it("faengt genau die Werte ab, die alles stillgelegt haetten", () => {
// Number("") ist 0, Number("abc") ist NaN. setTimeout loest bei beidem
// sofort aus – jeder ausgehende Aufruf braeche ab, bevor er beginnt,
// und der Abgleich meldete ueberall leere Listen.
const warnungen: string[] = [];
const merken = (text: string) => warnungen.push(text);
expect(zeitlimit("0", merken)).toBe(15000);
expect(zeitlimit("abc", merken)).toBe(15000);
expect(zeitlimit("-5000", merken)).toBe(15000);
expect(zeitlimit("500", merken)).toBe(15000);
// Und zwar nicht stillschweigend.
expect(warnungen).toHaveLength(4);
for (const warnung of warnungen) {
expect(warnung).toContain("GIT_API_TIMEOUT_MS");
}
});
});
describe("Teilabrufe", () => {
// Vor jedem Aufruf prueft der SSRF-Schutz das Ziel und loest dafuer den
// Hostnamen auf. Eine ausdrueckliche Freigabe ueberspringt das – sonst
// haengt der Test an einem DNS-Server und misst nicht, was er messen will.
beforeEach(() => {
vi.stubEnv("GIT_ALLOWED_HOSTS", "git.example.com");
});
afterEach(() => {
vi.unstubAllEnvs();
// Ohne das zaehlen die Spies ueber Tests hinweg weiter – und genau an
// Aufrufzahlen haengen die Pruefungen hier.
vi.restoreAllMocks();
});
it("meldet nichts, solange alles ankommt", async () => {
const teil = new Teilabrufe();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify([{ name: "main" }]), { status: 200 }),
);
const wert = await teil.hol("branches", "https://git.example.com/api/v1/x", []);
expect(wert).toEqual([{ name: "main" }]);
expect(teil.fehler).toEqual({});
expect(teil.unvollstaendig).toBe(false);
});
it("gibt den Fallback zurueck, haelt aber den Grund fest", async () => {
// Der Kern des Fehlers: vorher war dieser Fall von "das Repository hat
// keine Branches" nicht zu unterscheiden.
const teil = new Teilabrufe();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("no scopes", { status: 403 }),
);
const wert = await teil.hol("branches", "https://git.example.com/api/v1/x", []);
expect(wert).toEqual([]);
expect(teil.unvollstaendig).toBe(true);
expect(teil.fehler.branches).toContain("403");
});
it("nennt den Status, weil daran die Ursache haengt", async () => {
// 403 heisst Scopes, 429 Rate-Limit, 408 Zeitueberschreitung. Ohne die
// Zahl bliebe nur "hat nicht geklappt".
const teil = new Teilabrufe();
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("slow down", { status: 429 }));
await teil.hol("tags", "https://git.example.com/api/v1/x", []);
expect(teil.fehler.tags).toMatch(/429/);
});
it("haelt mehrere Fehlschlaege getrennt auseinander", async () => {
const teil = new Teilabrufe();
let aufruf = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
aufruf += 1;
// Der zweite geht durch – ein Teilausfall ist der haeufige Fall.
return aufruf === 2
? new Response(JSON.stringify([1, 2]), { status: 200 })
: new Response("nope", { status: 403 });
});
const [branches, tags, commits] = await Promise.all([
teil.hol("branches", "https://git.example.com/a", []),
teil.hol("tags", "https://git.example.com/b", []),
teil.hol("commits", "https://git.example.com/c", []),
]);
expect(branches).toEqual([]);
expect(tags).toEqual([1, 2]);
expect(commits).toEqual([]);
expect(Object.keys(teil.fehler).sort()).toEqual(["branches", "commits"]);
});
it("faengt auch einen Abbruch ab, nicht nur eine Fehlerantwort", async () => {
const teil = new Teilabrufe();
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("socket hang up"));
const wert = await teil.hol("commits", "https://git.example.com/api/v1/x", []);
expect(wert).toEqual([]);
expect(teil.fehler.commits).toBeTruthy();
});
});
/**
* Der zweite Versuch.
*
* Beobachtet an einer 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.
*/
describe("Teilabrufe: Nachschlag", () => {
beforeEach(() => {
vi.stubEnv("GIT_ALLOWED_HOSTS", "git.example.com");
});
afterEach(() => {
vi.unstubAllEnvs();
// Ohne das zaehlen die Spies ueber Tests hinweg weiter – und genau an
// Aufrufzahlen haengen die Pruefungen hier.
vi.restoreAllMocks();
});
/** Antwortet die ersten `fehlschlaege` Male mit Abbruch, danach mit Daten. */
function zaehlend(fehlschlaege: number, status = 408) {
let aufruf = 0;
return vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
aufruf += 1;
if (aufruf <= fehlschlaege) {
if (status === 408) {
const abbruch = new Error("aborted");
abbruch.name = "AbortError";
throw abbruch;
}
return new Response("nope", { status });
}
return new Response(JSON.stringify(["da"]), { status: 200 });
});
}
it("holt eine Zeitueberschreitung im zweiten Versuch nach", async () => {
const fetchSpy = zaehlend(1);
const teil = new Teilabrufe();
const wert = await teil.hol("branches", "https://git.example.com/a", []);
expect(wert).toEqual(["da"]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
// Geglueckt heisst: kein Eintrag. Sonst stuende am Repository ein
// Fehler, den es nicht mehr gibt.
expect(teil.fehler).toEqual({});
expect(teil.unvollstaendig).toBe(false);
});
it("vermerkt es, wenn auch der zweite Versuch scheitert", async () => {
const fetchSpy = zaehlend(99);
const teil = new Teilabrufe();
const wert = await teil.hol("commits", "https://git.example.com/a", []);
expect(wert).toEqual([]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(teil.fehler.commits).toContain("408");
expect(teil.fehler.commits).toContain("zweiten Versuch");
});
it("schlaegt bei 403 gar nicht erst nach", async () => {
// Fehlende Scopes werden durch Wiederholen nicht besser.
const fetchSpy = zaehlend(99, 403);
const teil = new Teilabrufe();
await teil.hol("tags", "https://git.example.com/a", []);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(teil.fehler.tags).not.toContain("zweiten Versuch");
});
it("schlaegt bei 429 nicht nach", async () => {
// Sofortiges Wiederholen macht ein Rate-Limit schlimmer, nicht besser.
const fetchSpy = zaehlend(99, 429);
const teil = new Teilabrufe();
await teil.hol("issues", "https://git.example.com/a", []);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("gibt hoechstens zwei Nachschlaege je Abgleich", async () => {
// Acht Zeitueberschreitungen zu je 15 s waeren zwei Minuten. Wer weg
// ist, soll schnell als weg erkannt werden.
const fetchSpy = zaehlend(99);
const teil = new Teilabrufe();
await Promise.all([
teil.hol("branches", "https://git.example.com/a", []),
teil.hol("tags", "https://git.example.com/b", []),
teil.hol("commits", "https://git.example.com/c", []),
teil.hol("releases", "https://git.example.com/d", []),
]);
// Vier erste Versuche, aber nur zwei Nachschlaege.
expect(fetchSpy).toHaveBeenCalledTimes(6);
expect(Object.keys(teil.fehler).sort()).toEqual([
"branches",
"commits",
"releases",
"tags",
]);
});
it("reisst die Kette nicht ab, wenn ein Nachschlag scheitert", async () => {
// Der erste Nachschlag geht schief, der zweite muss trotzdem laufen.
let aufruf = 0;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
aufruf += 1;
// 1 und 2 sind die ersten Versuche, 3 der erste Nachschlag (scheitert),
// 4 der zweite Nachschlag (glueckt).
if (aufruf <= 3) {
const abbruch = new Error("aborted");
abbruch.name = "AbortError";
throw abbruch;
}
return new Response(JSON.stringify(["da"]), { status: 200 });
});
const teil = new Teilabrufe();
const [erst, zweit] = await Promise.all([
teil.hol("branches", "https://git.example.com/a", []),
teil.hol("tags", "https://git.example.com/b", []),
]);
expect(fetchSpy).toHaveBeenCalledTimes(4);
expect(erst).toEqual([]);
expect(zweit).toEqual(["da"]);
expect(Object.keys(teil.fehler)).toEqual(["branches"]);
});
});