post

HTTP APIs: klein, stabil, lesbar

Was der Heroku HTTP API Design Guide taugt, wenn man ihn an eigenen APIs spiegelt – und warum wenige Ressourcen, ein Versionsversprechen und selbsterklärende Fehler mehr bewirken als jedes Framework.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (11 Min.)

MP3 herunterladen

Eine HTTP-API, die alle paar Wochen ihre Form ändert, erzieht ihre Clients – nur leider in die falsche Richtung. Nach dem zweiten Bruch baut jedes Client-Team eine Abwehrschicht: einen Mapper, der Felder umbenennt, bevor der Rest des Codes sie zu sehen bekommt. Ein Try/Catch um jeden Datumsparser, weil das Zeitformat schon zweimal gewechselt hat. Prüfungen auf fehlende Felder, auf drei verschiedene Fehlerformate, auf Statuscodes, die mal so und mal so gemeint sind. Nach einem halben Jahr besteht der Client zur Hälfte aus Code, der keine Fachlichkeit abbildet, sondern Misstrauen. Und weil jedes Team diese Schicht für sich erfindet, gibt es sie am Ende viermal, in vier Varianten, mit vier verschiedenen Fehlern.

Die Kosten entstehen also nicht dort, wo die API gebaut wird, sondern verteilt über alle, die sie benutzen. Das macht sie so leicht zu übersehen.

Im Mai hat Heroku seinen HTTP API Design Guide veröffentlicht – ein schmales Dokument auf GitHub, destilliert aus der Arbeit an der Heroku Platform API. Ich habe das Repository Ende Mai geforkt und seitdem Punkt für Punkt an APIs gespiegelt, die ich selbst betreue oder im Review sehe. Nicht alles davon würde ich unterschreiben. Aber die Grundhaltung trifft ziemlich genau das, was ich in eigenen Projekten als tragfähig erlebt habe: Eine gute HTTP-API ist klein, stabil und lesbar. Alles andere ist Zubehör.

Klein: eine Ressource muss sich ihren Platz verdienen

Der Guide sagt es nicht wörtlich, aber es steht zwischen allen Zeilen: wenige Ressourcen, dafür konsequente Konventionen. Ressourcennamen im Plural (/orders, nicht /order und schon gar nicht /getOrders), möglichst flache Pfade, IDs als kleingeschriebene UUIDs, auf jeder Ressource created_at und updated_at – ohne dass jemand danach fragen muss.

Das klingt banal, ist es aber nicht. In einem Projekt Anfang des Jahres haben wir eine API übernommen, die über sechzig Endpunkte hatte – für eine Fachlichkeit, die mit etwa zehn Ressourcen vollständig beschrieben war. Der Rest waren Bequemlichkeits-Endpunkte: /orders/search, /orders/latest, /ordersWithCustomer, jeweils entstanden, weil ein Client-Team eine bestimmte Sicht brauchte und der schnellste Weg ein neuer Endpunkt war. Jeder dieser Endpunkte hatte sein eigenes Antwortformat, seine eigene Vorstellung von Paginierung und seine eigene Lebensdauer. Keiner ließ sich entfernen, weil niemand wusste, wer ihn noch aufrief.

Seitdem gilt bei mir: Ein Endpunkt ist eine Verbindlichkeit, kein Feature. Jede Ressource, die ich veröffentliche, muss ich Jahre tragen. Deshalb frage ich vor jedem neuen Pfad zuerst, ob sich der Anwendungsfall nicht über eine bestehende Ressource plus Query-Parameter ausdrücken lässt. Meistens lautet die Antwort ja – und die API bleibt klein genug, dass ein neuer Entwickler sie an einem Vormittag versteht.

So sieht der Normalfall aus, auf den sich alles andere beziehen sollte:

GET /orders/01234567-89ab-cdef-0123-456789abcdef HTTP/1.1
Host: api.example.com
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
  "id": "01234567-89ab-cdef-0123-456789abcdef",
  "state": "confirmed",
  "total": 4990,
  "currency": "EUR",
  "created_at": "2014-06-20T08:30:00Z",
  "updated_at": "2014-06-21T10:15:00Z"
}

Nichts daran ist spektakulär. Genau das ist der Punkt: Wer diese eine Antwort gesehen hat, kann die Form jeder anderen Ressource der API erraten. Diese Vorhersagbarkeit ist das eigentliche Produkt.

Lesbar: der Fehler erklärt sich selbst

Der Teil des Guides, den ich sofort übernommen habe, ist das Fehlerformat. Heroku schlägt für jeden Fehler denselben Body vor: eine maschinenlesbare id, eine menschenlesbare message und eine url, die auf weiterführende Dokumentation zeigt.

{
  "id": "rate_limit",
  "message": "Account reached its API rate limit.",
  "url": "https://docs.example.com/rate-limits"
}

Warum mich das überzeugt: Es bedient beide Leser gleichzeitig. Der Client-Code verzweigt über die id, ohne Strings zu parsen. Der Mensch, der nachts um zwei im Log liest, versteht die message, ohne die Doku zu öffnen. Und die url beendet die Raterei, wenn er sie doch öffnen muss.

Der Kontrast zur Praxis ist deutlich. Mir ist in Reviews immer wieder dieselbe Sammlung begegnet: mal ein HTML-Fehlertext vom Proxy, mal {"error": true}, mal ein Stacktrace im Klartext, mal Status 200 mit einer Fehlermeldung im Body. Jede dieser Varianten zwingt den Client, einen weiteren Sonderfall in seine Abwehrschicht zu schrauben – womit wir wieder beim Anfang wären.

Mit Express 4, das seit April draußen ist, lässt sich ein einheitliches Fehlerformat mit sehr wenig Code erzwingen. Der Kern ist eine kleine Fabrik für Fehlerobjekte und genau ein zentraler Error-Handler, der als letzte Middleware registriert wird:

// errors.js - one error shape for the whole API
function apiError(status, id, message, url) {
  var err = new Error(message);
  err.status = status;
  err.id = id;
  err.url = url;
  return err;
}

app.get('/orders/:id', function (req, res, next) {
  store.findOrder(req.params.id, function (err, order) {
    if (err) { return next(err); }
    if (!order) {
      return next(apiError(404, 'order_not_found',
        'No order exists with the given id.',
        'https://docs.example.com/errors#order_not_found'));
    }
    res.json(order);
  });
});

// registered last, catches everything
app.use(function (err, req, res, next) {
  res.status(err.status || 500).json({
    id: err.id || 'internal_error',
    message: err.message || 'An unexpected error occurred.',
    url: err.url || 'https://docs.example.com/errors'
  });
});

Der Nebeneffekt, den ich nicht erwartet hatte: Die Liste der Fehler-IDs wird zu einem Katalog der Fachlichkeit. Wenn dort order_not_found, order_already_shipped und payment_declined stehen, liest sich das wie eine Kurzfassung der Domäne. Eine API, deren Fehler sich so lesen, braucht deutlich weniger Prosa-Dokumentation – die URL erzählt die Fachlichkeit im Guten, die Fehler-ID erzählt sie im Schlechten.

Stabil: Versionierung ist ein Versprechen, kein Header

Beim Thema Versionierung bin ich mit dem Guide nur halb einverstanden. Heroku empfiehlt, die Version über den Accept-Header zu verlangen – Accept: application/vnd.heroku+json; version=3 – und Anfragen ohne explizite Version abzulehnen. Das ist die sauberste Form: Die URL bezeichnet die Ressource, der Header verhandelt die Repräsentation. So war HTTP gedacht.

Nur: In den Projekten, die ich kenne, gewinnt fast immer die Version in der URL, also /v1/orders. Und zwar nicht aus Unwissenheit, sondern aus handfesten Gründen. Ein curl-Aufruf zum Nachstellen eines Fehlers braucht keinen zusätzlichen Header. Der Access-Log zeigt auf einen Blick, welche Version ein Client benutzt – wichtig, wenn man wissen will, ob /v1 endlich abgeschaltet werden kann. Proxies und Router können nach Pfad-Präfix trennen, ohne Header zu inspizieren. Und im Browser lässt sich eine GET-Antwort ohne Werkzeug ansehen. Der Accept-Header ist eleganter; die URL ist ehrlicher gegenüber dem Alltag von Betrieb und Fehlersuche. Ich habe beides im Einsatz und würde heute keinem Team die Header-Variante aufdrängen, das seine Logs liebt.

Wichtiger als der Transportweg der Versionsnummer ist ohnehin etwas anderes: was die Version verspricht. Eine Versionsnummer ist die Zusage, dass innerhalb dieser Version nichts bricht. Konkret heißt das für mich:

  • Neue Felder und neue Ressourcen dürfen jederzeit dazukommen – Clients müssen Unbekanntes ignorieren.
  • Bestehende Felder ändern weder Namen noch Typ noch Bedeutung.
  • Fehler-IDs und Statuscodes für bestehende Fälle bleiben, wie sie sind.
  • Wird eines davon verletzt, gibt es eine neue Version – und die alte läuft weiter, bis nachweislich niemand sie mehr aufruft.

Diese Entscheidungslogik ist klein genug für ein Diagramm, und ich hänge sie inzwischen tatsächlich an die Wand, wenn ein Team über eine Änderung diskutiert:

flowchart TD
  A[Änderung an der API] --> B{Nur additiv?<br/>Neue Felder, neue Ressourcen}
  B -- ja --> C[Gleiche Version<br/>Clients laufen unverändert weiter]
  B -- nein --> D{Ändert sich Name, Typ oder<br/>Bedeutung von Bestehendem?}
  D -- nein --> C
  D -- ja --> E[Neue Version<br/>alte Version läuft weiter,<br/>bis Logs sie für tot erklären]

Der unbequeme Teil steht im letzten Kasten. Eine neue Version auszurufen ist billig; die alte am Leben zu halten ist die eigentliche Arbeit. Wer das Versprechen nicht halten will, sollte lieber gar keine Versionsnummer vergeben – eine /v1, die trotzdem alle zwei Monate bricht, ist schlimmer als gar keine, weil sie Verlässlichkeit signalisiert, die es nicht gibt.

Zeitstempel und Paginierung – die unterschätzten Kleinigkeiten

Zwei Guide-Punkte wirken auf den ersten Blick wie Fußnoten, haben mir aber mehr Debugging-Stunden erspart als manche Architekturentscheidung.

Erstens: Zeiten ausschließlich als ISO 8601 in UTC, also 2014-06-20T08:30:00Z. Ich habe in einem Altsystem einmal drei Zeitformate in derselben API gehabt – Unix-Timestamps in Sekunden, Unix-Timestamps in Millisekunden und ein deutsches Datumsformat mit Punkt. Der Fehler, der daraus entstand, war ein Klassiker: Ein Client interpretierte Sekunden als Millisekunden, und plötzlich lagen alle Bestellungen im Januar 1970. Seit alles konsequent ISO 8601 mit Z am Ende ist, ist diese ganze Fehlerklasse verschwunden. Das Format ist sortierbar, eindeutig, und jeder Parser dieser Welt kennt es.

Zweitens: Paginierung. Der Guide verweist auf Herokus Range-Header – der Client fragt mit Range einen Ausschnitt an, der Server antwortet mit 206 Partial Content und nennt im Next-Range-Header den Schlüssel für die nächste Seite:

curl -i https://api.example.com/orders \
  -H "Range: id 01234567-89ab-cdef-0123-456789abcdef..; max=200"

# HTTP/1.1 206 Partial Content
# Content-Range: id 01234567-...;
# Next-Range: id 89abcdef-...; max=200

Technisch gefällt mir daran vor allem eines: Es ist Cursor-Paginierung. Der Client blättert ab einem Schlüssel weiter, nicht ab einem Offset – und bekommt deshalb keine Duplikate oder Lücken, wenn zwischen zwei Seiten neue Datensätze entstehen. Das klassische ?page=3 hat genau dieses Problem, und es fällt erst in Produktion auf, wenn die Daten sich schnell genug ändern.

Trotzdem bin ich beim Transportweg wieder pragmatischer als der Guide. Range-Header für etwas anderes als Bytes sind ungewohnt, viele HTTP-Bibliotheken machen den Zugriff auf Antwort-Header umständlicher als nötig, und im Team muss man die Konvention jedem neu erklären. In eigenen APIs bin ich bei Cursor-Paginierung über Query-Parameter gelandet – ?limit=200&after=<id> – mit einem next-Verweis im Body. Dieselbe Eigenschaft, gewöhnlichere Mechanik. Die Idee des Guides übernehme ich, seine Syntax nicht.

Genau das ist übrigens mein Verhältnis zu dem Dokument insgesamt. Es ist eine Sammlung von Entscheidungen, die ein Team für seine Plattform getroffen und öffentlich begründet hat – kein Standard, kein Gesetz. Der Wert liegt darin, dass man beim Lesen für jede eigene API gezwungen ist, Farbe zu bekennen: übernehmen, abweichen, aber nie wieder „haben wir uns nie überlegt“.

Was ich daraus mitnehme

Wer 2014 eine HTTP-API baut, hat das Formatproblem im Grunde gelöst – JSON hat gewonnen, die Verben stehen im RFC, und mit HAL und JSON Schema gibt es lebhafte Diskussionen darüber, wie viel Hypermedia eine API vertragen sollte. Das eigentliche Problem ist sozialer Natur: Eine API ist ein Versprechen an Leute, deren Code man nie sehen wird. Klein, stabil und lesbar sind die drei Eigenschaften, die dieses Versprechen einlösbar machen. Klein, damit man es überblicken kann. Stabil, damit man ihm trauen kann. Lesbar, damit man es ohne Dolmetscher versteht.

Der Heroku-Guide ist dafür die beste Checkliste, die mir bisher begegnet ist – gerade weil er so kurz ist und weil hinter jedem Punkt erkennbar eine echte Plattform steht. Mein Fork liegt seit Ende Mai in meinem GitHub-Konto, und die nützlichste Übung war nicht das Lesen, sondern das Spiegeln: jede eigene API daneben legen und Punkt für Punkt notieren, wo sie abweicht und ob es dafür einen Grund gibt. Bei mir blieben zwei bewusste Abweichungen übrig – Version in der URL, Paginierung per Query-Parameter – und eine unangenehm lange Liste von Stellen, an denen die Abweichung schlicht Nachlässigkeit war. Die arbeite ich gerade ab. Die Clients werden es nicht bemerken, und genau daran erkennt man, dass es die richtige Arbeit ist.

Weiterführende Quellen

Kommentare