7. Robuste Webservices
Robuste Webservices
Abschnitt betitelt „Robuste Webservices“In Kapitel 4 haben Sie die Schnittstelle entworfen, in Kapitel 6 steht die Datenbank dahinter. Jetzt wird sie gebaut, und zwar so, dass sie mehr aushält als den Gutfall.
Der Unterschied lässt sich an einem Satz festmachen. „Der Endpunkt liefert Daten” ist nach einer Doppelstunde erreicht. „Der Endpunkt hält aus, dass jemand zweimal auf Absenden drückt, dass ein Client zehntausend Datensätze auf einmal will, dass eine Mobilverbindung mitten in der Anfrage abbricht, dass jemand Unsinn schickt und dass ein anderer Endpunkt gleichzeitig unter Last steht” ist die Arbeit dieser zwei Wochen.
Endpunkte umsetzen
Abschnitt betitelt „Endpunkte umsetzen“Die Aufteilung folgt der Zugriffsschicht aus Kapitel 6, mit einer Ergänzung: Der Route Handler bleibt dünn. Er übersetzt zwischen HTTP und Fachlichkeit und tut sonst nichts.
export async function POST(request: Request) { const rawData = await request.json(); const input = CreateBookingSchema.parse(rawData); // throws on errors
const booking = await bookingService.create(input, currentUser(request));
return Response.json(booking, { status: 201, headers: { Location: `/api/bookings/${booking.id}` }, });}Alles Fachliche liegt im Dienst: Prüfung der Einschulung, Konflikt mit bestehenden Buchungen, Kontingent, Transaktion. Das ist keine Stilfrage. Ein Dienst ohne HTTP-Bezug ist mit einem gewöhnlichen Test prüfbar, und derselbe Dienst lässt sich später aus einem Hintergrundprozess oder einem Server Component aufrufen.
Eingaben validieren
Abschnitt betitelt „Eingaben validieren“Jede Eingabe von außen ist unbekannt, bis sie geprüft wurde. Das gilt auch dann, wenn Ihr eigenes Frontend der einzige Aufrufer ist, denn zwischen Frontend und Server liegt das Netz, und dort kann jeder mitschreiben (Kapitel 9).
Die Arbeitsweise heißt parsen statt prüfen: Statt einen unbekannten Wert zu untersuchen und danach weiterzuverwenden, wird er in einen typisierten Wert überführt, oder es gibt gar keinen Wert.
import { z } from "zod";
export const CreateBookingSchema = z.object({ machineId: z.number().int().positive(), start: z.coerce.date(), end: z.coerce.date(), note: z.string().max(500).optional(),}).refine((d) => d.end > d.start, { message: "End must be after start", path: ["end"],});
export type CreateBooking = z.infer<typeof CreateBookingSchema>;Der Typ wird aus dem Schema abgeleitet und nicht daneben geschrieben. Damit können Prüfung und Typ nicht auseinanderlaufen. Und dasselbe Schema wird in Kapitel 11 im Formular wiederverwendet, sodass Client und Server dieselbe Regel anwenden, statt zwei Auslegungen derselben Regel zu pflegen.
Fehler einheitlich beantworten
Abschnitt betitelt „Fehler einheitlich beantworten“Wenn jeder Endpunkt seine Fehler anders formuliert, muss jeder Aufrufer für jeden Endpunkt eine eigene Behandlung schreiben. Deshalb gibt es ein Format dafür: problem+json nach RFC 9457.
{ "type": "https://projekt.example.at/errors/time-slot-taken", "title": "Time slot taken", "status": 409, "detail": "The machine is already booked from 14:00 to 15:30.", "instance": "/api/bookings", "correlationId": "b3f1c2a9", "errors": { "start": "overlaps with booking 812" }}Die Antwort trägt den Inhaltstyp application/problem+json. type ist eine stabile Kennung, auf die ein Client prüfen kann, title ist für Menschen, detail beschreibt den konkreten Fall, und errors nimmt die Feldfehler aus der Validierung auf, damit das Formular sie an die richtigen Felder schreiben kann.
Erzeugt wird das an genau einer Stelle. Die Endpunkte werfen Fehler mit Bedeutung, eine zentrale Behandlung übersetzt sie in Antworten:
export function errorResponse(error: unknown, correlationId: string) { if (error instanceof z.ZodError) return problem(400, "Invalid input", error, correlationId); if (error instanceof NotFound) return problem(404, "Not found", error, correlationId); if (error instanceof Forbidden) return problem(403, "Forbidden", error, correlationId); if (error instanceof Conflict) return problem(409, "Conflict", error, correlationId);
logger.error({ correlationId, error }); // in full to the log return problem(500, "Internal error", undefined, correlationId); // terse to the outside}Die letzten beiden Zeilen sind der wichtige Teil. Ein Stacktrace, eine SQL-Fehlermeldung oder ein Dateipfad in der Antwort verrät einem Angreifer Ihre Struktur, Ihre Bibliotheksversionen und manchmal Ihr Datenmodell. Nach außen geht eine knappe Meldung samt Korrelations-ID, im Log steht alles. Wenn eine Schülerin dann meldet „bei mir kam Fehler b3f1c2a9”, finden Sie die Zeile in einer Sekunde. Das Thema Logging wird in der 5. Klasse ausgebaut.
Idempotenz und Wiederholungen
Abschnitt betitelt „Idempotenz und Wiederholungen“Clients wiederholen Anfragen. Nicht, weil sie schlecht programmiert sind, sondern weil sie müssen: Die Mobilverbindung bricht ab, bevor die Antwort ankommt, eine Zeitüberschreitung greift, der Benutzer drückt zweimal auf „Buchen”, weil eine Sekunde nichts passiert ist.
In allen drei Fällen weiß der Client nicht, ob der Server die Anfrage bekommen hat. Bei PUT und DELETE ist das egal, sie sind idempotent (Kapitel 4). Bei POST entstehen zwei Buchungen.
Die Lösung ist ein Schlüssel, den der Client mitschickt und über die Wiederholung hinweg beibehält:
POST /api/bookingsIdempotency-Key: 4f1c-9a2e-...-
Der Server sucht den Schlüssel in einer eigenen Tabelle.
-
Ist er unbekannt, wird er zusammen mit der Anfrage gespeichert und die Buchung normal ausgeführt. Die erzeugte Antwort wird zum Schlüssel abgelegt.
-
Ist er bekannt, wird die gespeicherte Antwort zurückgegeben, ohne etwas zu tun.
-
Einträge älter als 24 Stunden werden aufgeräumt.
Stellen Sie den Fall in Ihrem Projekt nach, bevor Sie die Lösung einbauen: Klicken Sie zweimal schnell auf Absenden, oder schicken Sie dieselbe Anfrage zweimal mit curl. Zwei identische Buchungen in der Liste sind überzeugender als jede Erklärung. Im Frontend gehört zusätzlich die Schaltfläche während des Absendens gesperrt, aber das ist Bequemlichkeit und kein Schutz: Der Schutz liegt auf dem Server.
Große Datenmengen
Abschnitt betitelt „Große Datenmengen“Eine Liste ohne Obergrenze ist eine Zeitbombe mit langer Zündschnur. Sie funktioniert das ganze Jahr, bis jemand die zehntausendste Buchung anlegt.
Paginierung
Abschnitt betitelt „Paginierung“Die verbreitete Variante nummeriert Seiten über ?page=3&limit=20 und übersetzt das in OFFSET. Sie ist einfach und hat zwei Schwächen: Sie wird mit steigendem Offset langsamer (Kapitel 6), und sie verschiebt sich, wenn zwischen zwei Seitenaufrufen ein Datensatz eingefügt wird. Der Benutzer sieht dann einen Eintrag zweimal oder gar nicht.
Die stabile Variante merkt sich die letzte gesehene Position:
GET /api/bookings?machineId=7&limit=20GET /api/bookings?machineId=7&limit=20&after=eyJzdGFydCI6IjIwMjYt...{ "data": [ ... ], "next": "eyJzdGFydCI6IjIwMjYtMDMtMTJUMTQ6MDA6MDBaIiwiaWQiOjgxMn0="}Zwei Dinge sind daran wichtig. Der Cursor enthält den Sortierwert und die ID, weil zwei Buchungen dieselbe Anfangszeit haben können; ohne diesen zweiten Wert ist die Reihenfolge nicht eindeutig und die Paginierung springt. Und die Sortierung muss dieselbe sein wie die im Cursor, sonst ergibt der Vergleich keinen Sinn.
Die Gesamtzahl aller Treffer ist übrigens teuer: Sie zwingt zu einer zweiten Abfrage über die ganze Menge. Liefern Sie sie nur, wenn die Oberfläche sie wirklich anzeigt.
Filter und Sortierung
Abschnitt betitelt „Filter und Sortierung“Beides gehört in den Vertrag aus Kapitel 4 und beides ist Angriffsfläche. Erlaubte Felder stehen in einer Positivliste, wie in Kapitel 6 gezeigt, und die Obergrenze für limit wird serverseitig erzwungen:
const limit = Math.min(Number(url.searchParams.get("limit") ?? 20), 100);Ohne diese Zeile bestimmt der Aufrufer, wie viel Arbeit Ihr Server macht.
Caching
Abschnitt betitelt „Caching“Die schnellste Antwort ist die, die nicht berechnet werden muss. HTTP hat dafür einen eingebauten Mechanismus, und Sie haben ihn in Kapitel 4 schon zur Hälfte kennengelernt.
Cache-Control: private, max-age=60ETag: "v7"Beim nächsten Aufruf schickt der Client If-None-Match: "v7". Hat sich nichts geändert, antwortet der Server mit 304 Not Modified ohne Rumpf. Das spart die Übertragung und, wenn Sie den ETag günstig berechnen, auch das Zusammenbauen der Antwort.
Ein Detail entscheidet hier über ein Datenleck: der Unterschied zwischen private und public. private erlaubt nur dem Browser des Benutzers, die Antwort zu speichern. public erlaubt es jeder Zwischenstation, auch dem Reverse Proxy. Eine personalisierte Antwort mit public landet damit im gemeinsamen Zwischenspeicher und wird dem nächsten Besucher ausgeliefert. Für alles, was von der Anmeldung abhängt, gilt private oder no-store.
Zwischengespeichert wird an vier Stellen, und dieselbe Landkarte kommt in Kapitel 13 wieder:
| Ebene | Wer | Typische Dauer |
|---|---|---|
| Browser | der Client selbst | Sekunden bis Minuten |
| Reverse Proxy | Traefik oder ein CDN | Minuten |
| Anwendung | Zwischenspeicher im Prozess | Sekunden |
| Datenbank | Abfrage- und Seitenpuffer | automatisch |
Das eigentliche Problem ist nicht das Speichern, sondern das Verwerfen. Eine geänderte Buchung muss aus allen Ebenen verschwinden, in denen sie liegt, sonst sehen Benutzer alte Daten und melden Fehler, die es nicht gibt. Kurze Gültigkeitsdauern sind für ein Schulprojekt der pragmatische Weg. Wie das Frontend seinerseits mit veralteten Daten umgeht, ist das Thema von Kapitel 10.
Ratenbegrenzung
Abschnitt betitelt „Ratenbegrenzung“Ihre Schnittstelle steht im Internet, und dort probiert jemand irgendwann aus, was passiert, wenn er zehntausend Anfragen pro Minute schickt. Auch ohne bösen Willen genügt eine Schleife in einem fremden Skript.
Begrenzt wird auf mehreren Ebenen: pro IP-Adresse gegen einfache Fluten, pro Benutzerkonto gegen missbräuchliche Nutzung, und pro Endpunkt dort, wo eine Anfrage teuer ist. Die Anmeldung bekommt eine eigene, strengere Grenze, weil sie das Ziel von Rateversuchen ist (Kapitel 8).
HTTP/1.1 429 Too Many RequestsRetry-After: 30Der Retry-After-Header ist kein Schmuck: Er sagt einem gutwilligen Client, wann er es wieder versuchen soll, statt ihn raten zu lassen.
Dazu gehören zwei Grenzen, die man leicht übersieht. Eine Zeitüberschreitung für alles, was Ihr Server seinerseits aufruft, denn ein Aufruf ohne Zeitlimit blockiert im Zweifel bis zum Sankt-Nimmerleins-Tag; in JavaScript ist das AbortSignal.timeout(5000). Und eine Größenbegrenzung für Anfragerümpfe, sonst schickt jemand ein 200 MB großes JSON und Ihr Container ist aus dem Speicher.
Versionierung
Abschnitt betitelt „Versionierung“Eine Änderung ist brechend, wenn ein bestehender Aufrufer danach nicht mehr funktioniert.
| Änderung | Brechend? |
|---|---|
| neues optionales Feld in der Antwort | nein |
| neuer Endpunkt | nein |
| neues Pflichtfeld in der Anfrage | ja |
| Feld umbenennen oder entfernen | ja |
| Typ eines Feldes ändern | ja |
| Statuscode für einen Fall ändern | ja |
| strengere Validierung | ja, für alle, die es bisher lockerer hatten |
Der beste Umgang mit brechenden Änderungen ist, sie zu vermeiden: additiv erweitern, altes Feld eine Weile mitliefern. Wenn es sich nicht vermeiden lässt, ist der übliche Weg eine Version im Pfad, also /api/v1/bookings und /api/v2/bookings, weil sie sichtbar, testbar und in Logs erkennbar ist. Die Alternative über einen Header ist eleganter und in der Fehlersuche unangenehmer.
Der Auslaufpfad ist immer derselbe: ankündigen, Frist setzen, entfernen. In der Antwort weist ein Hinweis auf die Ablösung hin, die alte Version läuft eine vereinbarte Zeit weiter, danach wird sie abgeschaltet. „Das nutzt ja noch niemand” ist eine Vermutung, und im Zweifel benutzt es das eigene Frontend in einer Version, die noch im Browser eines Benutzers offen ist.
Die Schnittstelle testen
Abschnitt betitelt „Die Schnittstelle testen“Die Testebenen kennen Sie aus SEW. Hier sind sie auf eine laufende Schnittstelle angewandt.
Einheitstests prüfen die Dienstschicht ohne HTTP und ohne Datenbank: Überschneidet sich diese Buchung mit jener? Wird ohne Einschulung abgelehnt? Diese Tests laufen in Millisekunden und sind der Ort für die fachlichen Regeln.
Integrationstests rufen den Endpunkt auf und arbeiten gegen eine echte, aber eigene Testdatenbank, üblicherweise ein zweiter PostgreSQL-Container. Jeder Test setzt seinen Ausgangszustand selbst und räumt auf, sonst hängt das Ergebnis von der Reihenfolge ab.
Vertragstests prüfen, ob Implementierung und OpenAPI-Beschreibung noch zusammenpassen. Das ist die Ebene, die in Kapitel 4 und 5 versprochen wurde: Die Antwort eines Endpunkts wird gegen das Schema aus der Beschreibung validiert.
const response = await fetch("/api/bookings?machineId=7").then((r) => r.json());const schema = loadSchemaFrom("docs/openapi.yaml", "Booking");
expect(validate(response.data[0], schema)).toBe(true);Der Wert dieses Tests zeigt sich, wenn jemand ein Feld umbenennt und die Beschreibung vergisst. Ohne Vertragstest fällt das im Frontend auf, im schlechteren Fall bei einem fremden Aufrufer, im schlechtesten in der Produktion. Mit Vertragstest fällt es in der Pipeline auf, die seit Kapitel 3 bei jedem Push läuft.
Diese Tests gehören in den Deployment-Ablauf: Wer sie nur lokal ausführt, führt sie an dem Tag nicht aus, an dem es darauf ankäme.
Häufige Stolperfallen
Abschnitt betitelt „Häufige Stolperfallen“- Jeder Endpunkt hat sein eigenes Fehlerformat. Der Client braucht dann für jeden Endpunkt eine eigene Behandlung, und die Fehleranzeige im Formular wird zur Fleißaufgabe.
- Validierung nur im Frontend. Der Server vertraut damit dem Client, und der Client ist alles, was HTTP sprechen kann.
- Stacktrace oder SQL-Meldung in der Antwort. Kostenlose Aufklärung für Angreifer und meist auch noch verwirrend für Benutzer.
- Liste ohne Obergrenze. Ein einziger Aufruf zieht die ganze Tabelle, und der Aufrufer bestimmt die Last.
- Offset-Paginierung mit instabiler Sortierung. Einträge erscheinen doppelt oder verschwinden, und niemand kann es reproduzieren.
Cache-Control: publicauf personalisierten Antworten. Der Proxy liefert die Daten des einen Benutzers an den nächsten aus.- Caching gesetzt, Verwerfen vergessen. Benutzer sehen alte Daten und melden Fehler, die längst behoben sind.
- Kein Idempotenzschutz bei
POST. Doppelklick oder Netzabbruch erzeugen zwei Datensätze. - Brechende Änderung ohne Version, weil es angeblich noch niemand nutzt.
- Tests laufen nur lokal. Damit sind sie genau dann nicht gelaufen, wenn es darauf angekommen wäre.
Lernergebnisse
Abschnitt betitelt „Lernergebnisse“- Anwenden: Endpunkte mit dünnen Handlern und fachlicher Logik in einer Dienstschicht umsetzen.
- Beurteilen: entscheiden, wann eine Server Action genügt und wann ein echter Endpunkt nötig ist.
- Anwenden: Eingaben am Rand mit einem Schema parsen und den Typ daraus ableiten.
- Anwenden: ein einheitliches Fehlerformat nach problem+json umsetzen und Fehler zentral behandeln.
- Beurteilen: entscheiden, welche Information in die Antwort gehört und welche nur ins Log.
- Erklären: erklären, warum Clients Anfragen wiederholen, und Idempotenz über einen Schlüssel herstellen.
- Anwenden: Paginierung mit stabiler Sortierung umsetzen und Offset gegen Cursor abwägen.
- Anwenden: Filter, Sortierung und Obergrenzen als Positivliste im Vertrag verankern.
- Anwenden: Caching-Kopfzeilen setzen, die Wirkung über 304 nachweisen und
privatevonpublicunterscheiden. - Anwenden: Ratenbegrenzung mit 429 und
Retry-Aftersowie Zeit- und Größenbegrenzungen einrichten. - Analysieren: beurteilen, ob eine Änderung brechend ist, und einen Auslaufpfad festlegen.
- Erschaffen: Einheits-, Integrations- und Vertragstests schreiben, die einen Bruch der Schnittstelle aufdecken.
Arbeit am Jahresprojekt
Abschnitt betitelt „Arbeit am Jahresprojekt“- Der Webservice ist gemäß der OpenAPI-Beschreibung aus Kapitel 4 implementiert.
- Alle Eingaben werden serverseitig geparst, alle Fehler kommen im selben Format zurück, mit Korrelations-ID.
- Listen sind paginiert und begrenzt, Filter und Sortierung laufen über Positivlisten.
- Mindestens ein Endpunkt liefert
ETagund beantwortetIf-None-Matchmit 304. POSTauf die wichtigste Ressource ist gegen doppelte Absendung abgesichert, der Nachweis liegt als Protokoll eines Doppelaufrufs bei.- Ratenbegrenzung ist aktiv, mindestens für Anmeldung und Schreibvorgänge.
- Einheits-, Integrations- und Vertragstests laufen in der Pipeline und schlagen bei einer Feldumbenennung an.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe - Ein Fehlerformat für alle Endpunkte
- Aufgabe - Doppelte Absendung: Idempotenz herstellen
- Aufgabe - Cursor-Paginierung mit stabiler Sortierung
- Aufgabe - Vertragstest bricht bei Feldumbenennung