Özgür Işık Damar
9 Min Lesezeit

Die Zoll-API, die 7 verschiedene Schemas zurückgab (und der Parser, der alle überlebt hat)

Notizen aus der Integration mit der mazedonischen Zoll-API bei Stork — 14 Seiten Doku, 14 Schemas in Produktion, und ein defensiver Parser, der sich in drei Tagen amortisiert hat.

api-integrationgocross-borderresilience

Die Dokumentation der mazedonischen Zoll-API war 14 Seiten lang. Unsere Integration ging in den ersten vier Wochen in Produktion 11-mal kaputt. Jeder Bruch kam mit demselben 200-OK-Status, demselben application/json-Header und einer komplett anderen Payload-Form. Das Feld declaration_id kam einmal als String, zweimal als Integer, dreimal verschachtelt in einem result-Objekt, und einmal — genau einmal — als Teil eines XML-Envelopes innerhalb eines JSON-Strings. Letzteres hat mich zwei Tage gekostet.

Das hier ist die Geschichte, wie ich aufgehört habe, der Zoll-API zu vertrauen, und angefangen habe, sie zu parsen wie einen feindlichen Zeugen.

Was wir eigentlich gemacht haben

Stork verschickt Waren über die türkisch-nordmazedonische Grenze. Jede Sendung, egal wie klein, braucht eine Zollanmeldung bei der mazedonischen Zollbehörde. Es gibt eine offizielle API. Genau eine. Das ist der einzige Weg, die Anmeldung einzureichen, ohne dass jemand mit einem Ordner voller Ausdrucke zum Zollamt in Skopje fahren muss.

Also haben wir sie benutzt. Die erste Version der Integration habe ich im Februar 2024 in zwei Wochen geschrieben. In ihrer Sandbox lief alles wunderbar. In Produktion lief es drei Tage lang. Am vierten Tag — Dienstag, 14:22 — rief unser Country Manager in Skopje an und sagte, im Lager stünden 38 Pakete fest, weil die Anmeldungen zwar „durchgingen", aber ohne IDs zurückkamen, die wir speichern konnten.

Die API antwortete mit 200. Der Body sah gut aus. Das Feld declaration_id, das laut Doku ein Integer im Root sein sollte, war jetzt ein String. Einfach so. Kein Grund angegeben. Kein Versions-Header geändert.

Ich habe es an dem Nachmittag gepatcht. Elf Tage später ist es wieder kaputt gegangen, auf eine andere Art.

Elf Brüche im ersten Monat

Ich habe ein Notizbuch geführt. So sah der erste Monat aus, anonymisiert in Schema-Kategorien:

  1. Flache Felder, declaration_id im Root, Integer. Die dokumentierte Form. Lief drei Tage.
  2. Gleiche Form, aber declaration_id ist jetzt ein String. Keine Ahnung warum. Vermutlich ein anderes Backend.
  3. Eingewickelt in ein result-Objekt. { "result": { "declaration_id": 12345, ... } }. Tauchte nur zwischen 09:00 und 11:00 Skopjer Zeit auf. Wir wissen immer noch nicht, warum.
  4. In ein data-Array der Länge eins gepackt. { "data": [ { ... } ] }. Tauchte nach einem Deploy auf der Zollseite auf, von dem wir drei Tage später erfuhren.
  5. Wie (4), aber das Array konnte bei einer erfolgreichen Einreichung leer sein. Die Declaration-ID stand woanders — in einem separaten meta-Objekt. Ich hätte fast einen Parser ausgeliefert, der das leere Array gelesen und stillschweigend eine Anmeldung ohne ID gespeichert hätte. Ich habe es im Code Review gerade noch gesehen.
  6. HTML-Seite mit 200 zurückgegeben. Das war das Zoll-Gateway, das in einen Timeout lief und seine eigene Fehlerseite ausspielte. Mit Content-Type: application/json. Natürlich.
  7. JSON-Envelope mit XML-String drin. Dazu komme ich gleich.

Dieses XML-in-JSON-Ding. Darüber will ich reden.

Die zwei Tage, die ich verloren habe

Es war ein Donnerstag Ende März. Die Integration fing an, alle paar Stunden zu scheitern. Die Fehler sahen zufällig aus. Der Body war JSON. Er parste. Aber das Feld declaration_id war ein String, der mit <?xml version="1.0" anfing.

Ich dachte, es wäre ein Bug in unserem Logging — dass wir irgendwo das falsche Feld speicherten und das XML reinleckte. Ich habe einen Tag damit verbracht, diesen Bug zu suchen. Es gab keinen.

Die Zoll-API gab tatsächlich ein JSON-Objekt zurück, dessen declaration_id-Wert ein serialisierter XML-Envelope war. Im XML-Envelope steckte die tatsächliche Integer-ID, eingewickelt in einen <DeclarationResponse>-Tag, mit drei Namespace-Schichten.

Ich habe das API-Team angerufen. In der Mail-Signatur stand „Customs IT, Skopje Office, Helpdesk". Ein Mann ging ran und erklärte mir auf Mazedonisch, was meine Kollegin übersetzt hat, es habe „ein Upgrade" gegeben, und das JSON-zu-XML-Gateway serialisiere die innere Antwort jetzt anders. Ich fragte, ob es zurückgerollt werde. Er sagte, er werde nachsehen. Zwei Wochen später war es zurückgerollt. Bis dahin hatte ich schon einen Parser-Branch dafür geschrieben.

Den Branch habe ich drin gelassen. Drei Monate später kam dieses Schema für einen Nachmittag zurück.

Der defensive Parser

Das hier hätte ich an Tag eins bauen sollen und habe ich an Tag zwölf gebaut.

Die Idee ist einfach: Behandle die Antwort zuerst als untypisiertes JSON. Erkenne, welches Schema du gerade vor dir hast. Reiche an einen bekannten Parser weiter. Wenn kein Parser passt, alarmiere und falle zurück.

// Der dokumentierten Form vertrauen wir nicht. Wir erkennen sie.
// Jeder Eintrag in der Registry ist ein (Predicate, Parser, Label).
type schemaEntry struct {
    label     string
    detect    func(map[string]any) bool
    parse     func(map[string]any) (Declaration, error)
    lastSeen  time.Time
}
 
var registry = []*schemaEntry{
    flatRootIntID,      // dokumentierte Form
    flatRootStringID,   // erschien 2024-02-20
    nestedInResult,     // erschien 2024-02-27
    wrappedInDataArray, // erschien 2024-03-06
    xmlInsideJSON,      // erschien 2024-03-21, 2024-06-14
}

Jedes Schema hat sein eigenes Predicate. Das Predicate ist das Billigste, was du schreiben kannst, um die Form eindeutig zu identifizieren — meist ein einzelner Key-Lookup, manchmal eine Regex auf einem Wert.

// Erkennung muss billig sein. Nicht clever werden, einfach Keys checken.
// Die Reihenfolge zählt: spezifischere Formen zuerst, generische zuletzt,
// weil sich zwei Formen an einem guten Tag überlappen können.
func detectSchema(body map[string]any) *schemaEntry {
    for _, entry := range registry {
        if entry.detect(body) {
            entry.lastSeen = time.Now()
            return entry
        }
    }
    return nil
}

Wenn detectSchema nil zurückgibt, ist das im API-Sinn kein Fehler — der Request war erfolgreich, der Body hat als JSON geparst, der Status war 200. Aber im Sinn von Vertrauen ist es einer: Wir haben gerade eine Form gesehen, die wir noch nie gesehen haben. Wir müssen davon erfahren, bevor ein Mensch merkt, dass das Lager feststeckt.

// Unbekannte Form => Alarm, zurück auf Polling.
// Polling ist langsam (~6 Min), aber es lügt uns nie an.
func dispatchUnknown(raw []byte, shipmentID string) {
    alerts.Page("customs:unknown_schema", map[string]string{
        "shipment_id": shipmentID,
        "raw_excerpt": string(raw[:min(400, len(raw))]),
    })
    queue.Enqueue("customs:manual_review", shipmentID)
    poller.Schedule(shipmentID, 6*time.Minute)
}

Das Polling-Fallback geht an einen anderen Zoll-Endpoint — den, der Anmeldungen nach unserer internen Referenz auflistet. Er ist langsam. Er ist alt. Er gibt CSV zurück. Aber in zwei Jahren hat er nichts zurückgegeben, was wir nicht parsen konnten. Wenn die schicke API uns überrascht, fallen wir auf die langweilige zurück und warten. Niemand bleibt im Lager hängen. Der Pager weckt mich nur, wenn das unbekannte Schema bis nach dem Frühstück bleibt.

Der XML-in-JSON-Branch, in Code

Hier ist der Parser-Branch, für dessen Notwendigkeit ich zwei Tage gebraucht habe und für dessen Schreiben zwanzig Minuten, sobald ich die Form verstanden hatte:

// Das Zoll-Gateway serialisiert die innere Antwort manchmal als XML
// und packt sie als JSON-Feld string-codiert ein. Wir wickeln beide Schichten aus.
// Zuletzt gesehen: 2024-06-14 (einen Nachmittag, dann wieder weg).
func parseXMLInsideJSON(body map[string]any) (Declaration, error) {
    raw, ok := body["declaration_id"].(string)
    if !ok || !strings.HasPrefix(raw, "<?xml") {
        return Declaration{}, errWrongShape
    }
    var env struct {
        XMLName xml.Name `xml:"DeclarationResponse"`
        ID      int64    `xml:"id"`
    }
    if err := xml.Unmarshal([]byte(raw), &env); err != nil {
        return Declaration{}, fmt.Errorf("xml-in-json unwrap: %w", err)
    }
    return Declaration{ID: env.ID, SchemaUsed: "xml-in-json"}, nil
}

Das Feld SchemaUsed wird mit in die DB-Zeile geschrieben. Jede Anmeldung in unserer Datenbank weiß, aus welcher Schema-Variante sie kommt. Das war für Postmortems leise nützlich — wenn an einer Sendung drei Wochen später etwas seltsam aussieht, können wir nach Schema gruppieren und sehen, ob eine bestimmte Variante still degradiert ist.

Die Registry nach sechs Monaten

Nach sechs Monaten in Produktion hatte die Registry 14 verschiedene Einträge. Die Hälfte davon haben wir über den Fallback-Alarm entdeckt — die API überraschte uns, das Polling sprang an, und ein Entwickler hat den neuen Branch innerhalb von Stunden ergänzt. Mittlere Zeit vom Unknown-Schema-Alarm bis zum deployten Parser-Branch: ungefähr vier Stunden. Kundenseitig sichtbare Ausfälle durch die Zoll-API insgesamt: null. Wochenenden, die ich ohne dieses Design verloren hätte: ich zähle sechs. Ich bin konservativ.

Die Registry führt auch last_seen mit. Drei der frühen Varianten sind seit acht Monaten nicht mehr aufgetaucht. Wir lassen die Branches trotzdem drin, weil Entfernen billiges Risiko ist und Wiederhinzufügen um 2 Uhr morgens an einem Sonntag teuer ist.

Was ich mir im Februar 2024 gesagt hätte

Zwei Dinge.

Erstens: Vertraue dem Content-Type nicht. Das Zoll-Gateway wird dir sagen, der Body sei JSON, wenn er in Wahrheit XML, HTML oder ein JSON-Objekt mit einem XML-String drin ist. Der Header ist ein Hinweis, keine Tatsache. Parse den Body und prüfe seine Form, bevor du handelst.

Zweitens: Defensives Parsen ist nicht langsam. Registry, Dispatcher, Fallback-Poller und Alarm zu bauen hat mich drei Tage gekostet. Die naive Version hatte zwei Wochen gedauert und war elfmal kaputtgegangen. Hätte ich von Anfang an defensiv gebaut, wäre ich eine Woche früher live gegangen und hätte sechs Wochenenden geschlafen, die ich nicht geschlafen habe. Das Kosten-Nutzen-Verhältnis ist hier genau das Gegenteil von dem, was der „Premature-Optimization"-Reflex einem einredet. Das Teure war hier die Annahme, die Doku beschreibe die Wirklichkeit.

Die Flitterwochen

Letzten Sommer, eine ganze Woche lang — Montag bis Freitag, August 2024 — gab die Zoll-API ein perfektes, sauber typisiertes Schema zurück. Flache Felder, Integer-ID, keine Überraschungen. Jede Form lief durch denselben Branch. Der Fallback-Poller saß untätig. Der Alarm-Kanal war still.

Wir haben diese Woche in unserem Metrics-Dashboard separat geloggt. Der Chart hat immer noch ein kleines Etikett. Wir nannten sie „die Flitterwochen". Sie ist nicht wieder vorgekommen.

// wenn du schon hier bist