Özgür Işık Damar
9 dk okuma

7 farklı şema döndüren gümrük API'si (ve hepsinden sağ çıkan parser)

Stork'ta Makedonya gümrük API'siyle entegrasyon notları — 14 sayfa dokümantasyon, prodüksiyonda 14 şema ve üç günde kendini amorti eden defansif parser.

api-integrationgocross-borderresilience

Makedonya gümrük API'sinin dokümantasyonu 14 sayfaydı. Entegrasyonumuz ilk ay prodüksiyonda 11 kez patladı. Her patlama aynı 200 OK statüsüyle, aynı application/json header'ıyla ve tamamen farklı bir payload şekliyle geldi. declaration_id alanı bir kez string olarak geldi, iki kez integer olarak, üç kez bir result objesinin içine gömülü olarak ve bir kez — sadece bir kez — JSON stringin içine sarılmış bir XML envelope'un parçası olarak. Sonuncusu iki günümü aldı.

Bu yazı, gümrük API'sine güvenmeyi bırakıp onu düşman bir tanık gibi parse etmeye başlamamın hikayesi.

Aslında ne yapıyorduk

Stork, Türkiye ile Kuzey Makedonya sınırından mal taşıyor. Her gönderi, ne kadar küçük olursa olsun, Makedonya gümrük otoritesine resmi bir beyanname gerektiriyor. Resmi bir API var. Tek bir tane. Beyannameyi göndermenin tek alternatif yolu Üsküp'teki gümrük ofisine bir klasör dolusu çıktıyla arabayla gitmek.

Biz de API'yi kullandık. Entegrasyonun ilk versiyonunu Şubat 2024'te iki haftada yazdım. Sandbox'ta tertemiz çalıştı. Prodüksiyonda da ilk üç gün çalıştı. Dördüncü gün, Salı 14:22, Üsküp'teki ülke müdürümüz aradı: depoda 38 paket sıkışmıştı. Beyannameler "geçiyor" görünüyordu ama bize kaydedebileceğimiz bir ID dönmüyordu.

API 200 dönüyordu. Body sorunsuz görünüyordu. Dokümantasyonun "root'ta integer olacak" dediği declaration_id alanı artık stringdi. Düpedüz string. Sebep yok. Versiyon header'ı değişmemiş.

Aynı öğleden sonra patch'ledim. On bir gün sonra başka bir şekilde patladı.

İlk aydaki on bir patlama

Bir defter tuttum. İlk ay, anonimleştirilmiş şema kategorileri olarak şöyleydi:

  1. Düz alanlar, root'ta declaration_id, integer. Dokümante edilmiş şema. Üç gün çalıştı.
  2. Aynı şema, ama declaration_id artık string. Sebep belirsiz. Muhtemelen farklı bir backend.
  3. result objesinin içine sarılı. { "result": { "declaration_id": 12345, ... } }. Sadece Üsküp saatiyle 09:00–11:00 arası ortaya çıkıyordu. Hâlâ neden bilmiyoruz.
  4. Tek elemanlı bir data dizisinin içine sarılı. { "data": [ { ... } ] }. Üç gün sonra haberdar olduğumuz bir gümrük tarafı deploy'undan sonra çıkmaya başladı.
  5. (4) ile aynı, ama dizi başarılı bir gönderimde boş gelebiliyordu. Beyanname ID'si başka yerdeydi, ayrı bir meta objesinde. Boş diziyi okuyup sessizce ID'siz beyanname kaydeden bir parser shipe edecektim. Az kalsın. Code review'da yakaladım.
  6. 200 ile dönen HTML sayfası. Gümrük gateway'i timeout olup kendi error sayfasını servis ediyordu. Content-Type: application/json ile, tabii ki.
  7. JSON envelope'un içinde XML string. Buna birazdan döneceğim.

Şu XML-in-JSON olayı. Hakkında konuşmak istiyorum.

Kaybettiğim iki gün

Mart sonunda bir Perşembeydi. Entegrasyon birkaç saatte bir patlamaya başladı. Hatalar rastgele görünüyordu. Body JSON'dı. Parse oluyordu. Ama declaration_id alanı <?xml version="1.0" ile başlayan bir stringdi.

Logging tarafımızda bir bug olduğunu düşündüm. Bir yerlerde yanlış alan saklıyorduk ve XML oraya sızıyordu. Bütün bir günü o bug'ı aramakla geçirdim. Öyle bir bug yoktu.

Gümrük API'si gerçekten de değeri serialize edilmiş bir XML envelope olan bir declaration_id alanı dönüyordu. XML envelope'un içinde gerçek integer ID, bir <DeclarationResponse> tag'ine sarılı, üç katman namespace ile geliyordu.

API ekibini aradım. E-posta imzasında "Customs IT, Skopje Office, Helpdesk" yazıyordu. Bir bey açtı, mesai arkadaşımın çevirdiği Makedoncayla bana "bir upgrade" olduğunu, JSON-to-XML gateway'inin iç response'u artık farklı serialize ettiğini söyledi. Geri alınacak mı diye sordum. Bakacağını söyledi. İki hafta sonra geri alındı. O zamana kadar zaten o şema için bir parser branch'i yazmıştım.

Branch'i bıraktım. Üç ay sonra o şema bir öğleden sonralığına geri geldi.

Defansif parser

İşte birinci günde yapmam gereken ve on ikinci günde yaptığım şey.

Fikir basit. Response'u önce tipsiz JSON olarak ele al. Hangi şemaya baktığını tespit et. Bilinen bir parser'a dispatch et. Hiçbir parser eşleşmezse alert ver ve fallback'e geç.

// Dokümante şemaya güvenmiyoruz. Tespit ediyoruz.
// Registry'deki her giriş bir (predicate, parser, label) üçlüsü.
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,      // dokümante şema
    flatRootStringID,   // 2024-02-20'de görüldü
    nestedInResult,     // 2024-02-27'de görüldü
    wrappedInDataArray, // 2024-03-06'da görüldü
    xmlInsideJSON,      // 2024-03-21, 2024-06-14'te görüldü
}

Her şema kendi predicate'ini alıyor. Predicate, şekli tek başına ayırt eden en ucuz şey olmalı. Genelde tek bir key kontrolü, bazen bir değer üzerinde regex.

// Tespit ucuz olmalı. Akıllı olma, key'lere bak.
// Sıra önemli: önce daha spesifik şemalar, en sonda generic.
// İyi bir günde iki şema birbirine binebilir.
func detectSchema(body map[string]any) *schemaEntry {
    for _, entry := range registry {
        if entry.detect(body) {
            entry.lastSeen = time.Now()
            return entry
        }
    }
    return nil
}

detectSchema nil döndüğünde bu API anlamında bir hata değil. İstek başarılı, body JSON olarak parse oluyor, status 200. Ama güven anlamında bir hata: daha önce hiç görmediğimiz bir şekille karşılaştık. Depoda biri sıkıştığını fark etmeden önce bunu öğrenmemiz gerek.

// Bilinmeyen şekil => alert + polling fallback.
// Polling yavaş (~6 dk) ama bize asla yalan söylemiyor.
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)
}

Polling fallback farklı bir gümrük endpoint'ine vuruyor. Bizim iç referansımıza göre beyannameleri listeleyen eski olan. Yavaş. Eski. CSV dönüyor. Ama iki yıldır parse edemediğimiz bir şey döndürmedi. Yeni API bizi şaşırttığında sıkıcı olana fallback yapıp bekliyoruz. Depoda kimse sıkışmıyor. Bilinmeyen şema kahvaltıdan sonra da devam ederse beni uyandırıyor.

XML-in-JSON branch, kodla

İşte iki gün ihtiyaç beklediğim ve şekli anladıktan sonra yirmi dakikada yazılan parser branch'i:

// Gümrük gateway'i bazen iç response'u XML olarak serialize edip
// onu bir JSON alanı olarak string'e gömüyor. İki katmanı da açıyoruz.
// Son görüldüğü: 2024-06-14 (bir öğleden sonra, sonra gene gitti).
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
}

SchemaUsed alanı satırla beraber DB'ye yazılıyor. Veritabanımızdaki her beyanname hangi şema varyantından geldiğini biliyor. Bu, postmortem'lerde sessizce işe yaradı. Üç hafta sonra bir gönderide bir şey ters göründüğünde şemaya göre gruplayıp belirli bir varyantın sessiz sessiz bozulup bozulmadığına bakabiliyoruz.

Altı ay sonra registry

Prodüksiyonda altı ay sonra registry'de 14 farklı giriş vardı. Yarısını fallback alert sayesinde keşfettik. API bizi şaşırttı, polling devreye girdi, bir mühendis saatler içinde yeni branch'i ekledi. Unknown-schema alert'inden deploy'lu parser branch'ine kadar olan ortalama süre: yaklaşık dört saat. Gümrük API'sinin sebep olduğu müşteri görünür outage: sıfır. Bu tasarım olmasaydı kaybedeceğim hafta sonu sayısı: altı sayıyorum. Tutucu davranıyorum.

Registry last_seen de tutuyor. Erken varyantlardan üçü sekiz aydır ortaya çıkmadı. Branch'leri yine de tutuyoruz, çünkü çıkarmak ucuz risk, bir Pazar gecesi 02:00'de geri eklemek pahalı.

Şubat 2024'teki kendime ne söylerdim

İki şey.

Birincisi: Content-Type'a güvenme. Gümrük gateway'i body'nin JSON olduğunu söyleyecek, ama gerçekte XML, HTML veya XML string tutan bir JSON objesi olabiliyor. Header bir ipucu, gerçek değil. Eyleme geçmeden önce body'yi parse et ve şeklini kontrol et.

İkincisi: defansif parsing yavaş değildir. Registry'yi, dispatcher'ı, fallback poller'ı ve alert'ı kurmak üç günümü aldı. Naif versiyon iki haftamı almıştı ve on bir kez patlamıştı. Baştan defansif kursaydım bir hafta erken deploy etmiştim ve atlattığım altı hafta sonuna gerek kalmazdı. Buradaki cost-benefit, "premature optimization" refleksinin söyleyeceğinin tam tersi. Buradaki pahalı şey, dokümantasyonun gerçekliği anlattığı varsayımıydı.

Balayı

Geçen yaz, tam bir hafta boyunca, Pazartesi'den Cuma'ya, Ağustos 2024, gümrük API'si kusursuz, iyi tipli bir şema döndü. Düz alanlar, integer ID, sürpriz yok. Her şekil aynı branch'ten geçti. Fallback poller boşta oturdu. Alert kanalı sessizdi.

O haftayı metrics dashboard'ımızda ayrı logladık. Grafiğin hâlâ küçük bir etiketi var: "balayı". Üsküp'teki ülke müdürümüz Cuma akşamı Slack'e bir tek mesaj attı, "her şey tıkır tıkır, ne yapıyorsunuz orada?" Yapacak bir şey yoktu, sadece ekrana bakıyorduk.

// madem buradasın