Debug parser API yang kadang mengembalikan HTTP 500 hampir selalu lebih rumit daripada bug validasi biasa. Gejalanya tampak acak di produksi, tetapi sebenarnya dipicu oleh bentuk input tertentu yang lolos tahap parsing awal lalu gagal saat AST dipakai lebih lanjut oleh evaluator, planner, atau query builder.
Dalam studi kasus ini, masalah utamanya adalah ambiguitas grammar dan aturan prioritas yang salah di kombinator parser. Akibatnya, satu input bisa diparse ke struktur yang tidak konsisten: kadang diterima, kadang memicu exception di tahap berikutnya. Artikel ini membahas cara menemukannya secara praktis dari log, tracing, minim reproduksi, hingga perbaikan parser dan tes regresi.
Studi kasus: API filter expression yang 500 hanya pada input tertentu
Bayangkan sebuah endpoint backend menerima ekspresi filter:
POST /search
{
"filter": "status = 'active' OR role = 'admin' AND deleted = false"
}Backend memiliki parser untuk mengubah string filter menjadi AST, lalu AST itu dipakai untuk membangun query ke database atau mesin pencarian internal.
Mayoritas request berjalan normal. Namun pada sebagian kecil input, API mengembalikan 500. Dari sisi klien, error tampak intermiten karena:
- Hanya muncul pada kombinasi operator tertentu.
- Tidak semua data memicu jalur eksekusi yang sama.
- Input secara sintaks tampak valid, jadi tidak tertangkap sebagai 400.
Masalah seperti ini sering terjadi ketika parser dibangun cepat dengan parser combinator atau grammar ad-hoc, tetapi aturan asosiasi dan prioritas operator tidak dirumuskan ketat.
Gejala awal di log dan tracing
Pola pertama yang perlu dicari bukan sekadar status 500, tetapi korelasi antara input dan bentuk AST. Tanpa itu, tim sering terjebak mengira masalahnya ada di database, timeout, atau race condition.
Gejala yang biasa terlihat
- 500 hanya muncul pada request dengan ekspresi yang mengandung campuran
AND,OR, unary operator sepertiNOT, atau tanda kurung. - Log parser menyatakan parse berhasil, tetapi stack trace gagal di tahap normalisasi AST atau translasi ke query backend.
- Sebagian request identik secara bisnis, namun urutan token atau adanya spasi/tanda kurung membuat hasil berbeda.
Contoh log yang berguna
[request_id=8f2a] parse_input="status = 'active' OR role = 'admin' AND deleted = false"
[request_id=8f2a] parse_success ast={"type":"Binary","op":"AND",...}
[request_id=8f2a] normalize_error error="expected boolean expression on left side of AND"
[request_id=8f2a] response_status=500Di sini sinyal pentingnya adalah parse berhasil, tetapi AST tidak memenuhi invariant yang diasumsikan tahap berikutnya. Itu petunjuk kuat bahwa masalah bukan sekadar tokenisasi, melainkan struktur parse.
Instrumentasi tracing yang sebaiknya ditambahkan
- Request ID untuk mengaitkan log parser, normalizer, dan executor.
- Input mentah atau versi yang sudah disanitasi bila sensitif.
- AST hasil parse dalam format terstruktur dan ringkas.
- Nama rule parser terakhir sebelum sukses atau gagal.
- Klasifikasi error: parse error, AST validation error, semantic error, backend execution error.
Tanpa pemisahan kategori error, bug parser sering tersembunyi di bawah label umum seperti internal server error.
Akar masalah: grammar ambigu dan prioritas operator yang salah
Ambiguitas grammar berarti satu input dapat memiliki lebih dari satu cara parse yang valid, atau parser memilih struktur yang tidak sesuai maksud bahasa. Dalam API parser, ini berbahaya karena tahap setelah parse biasanya mengasumsikan AST sudah kanonik.
Ekspresi boolean adalah sumber masalah klasik. Misalnya, bahasa filter ini mendukung:
- Perbandingan:
field = value - Operator boolean:
AND,OR - Unary:
NOT - Grouping:
(...)
Contoh grammar yang tampak sederhana tetapi ambigu
Expr := Expr 'OR' Expr
| Expr 'AND' Expr
| 'NOT' Expr
| '(' Expr ')'
| Predicate
Predicate := Identifier Operator ValueSecara teori, grammar seperti ini bermasalah karena tidak menyatakan prioritas dan asosiasi operator. Input berikut ambigu:
a OR b AND cApakah artinya:
(a OR b) AND c, ataua OR (b AND c)?
Dalam kebanyakan bahasa ekspresi, AND punya prioritas lebih tinggi daripada OR, sehingga interpretasi kedua biasanya benar. Namun parser combinator yang salah dirangkai bisa menghasilkan bentuk pertama, atau lebih buruk, struktur campuran yang sulit diproses.
Contoh AST yang menyebabkan crash tahap berikutnya
Misalnya normalizer mengasumsikan node boolean biner hanya berisi ekspresi boolean yang sudah tervalidasi:
Binary(op=AND,
left=Binary(op=OR, left=Predicate(status='active'), right=Predicate(role='admin')),
right=Predicate(deleted=false)
)AST di atas masih valid secara semantik pada banyak implementasi. Tetapi jika parser keliru mengikat unary atau perbandingan, bentuk berikut bisa muncul:
Binary(op=AND,
left=Binary(op=OR, left=Predicate(status='active'), right=Identifier(role)),
right=Binary(op='=', left=Literal('admin'), right=Predicate(deleted=false))
)Parser mengaku sukses, tetapi AST tersebut rusak secara struktural. Saat normalizer atau SQL builder mencoba mengunjungi node, ia menemukan kombinasi tipe yang tidak mungkin menurut desain semantik, lalu melempar exception dan menghasilkan 500.
Mengapa bug ini lolos tes biasa
Bug parser seperti ini sering lolos karena tes yang ada hanya memeriksa jalur bahagia:
- Ekspresi tunggal:
status = 'active' - Dua predikat dengan satu operator:
a AND b - Input yang sudah diberi tanda kurung lengkap.
Yang sering tidak diuji:
- Kombinasi
ANDdanORtanpa tanda kurung. - Interaksi
NOTdengan comparison dan grouping. - Input yang valid secara token tetapi menghasilkan AST non-kanonik.
- Property test bahwa parse selalu memenuhi invariant struktur.
Kesalahan umum lainnya adalah tes hanya memeriksa parse success, bukan bentuk AST. Padahal parser bisa sukses dengan AST yang salah.
Mengisolasi bug dengan input minimal reproduksi
Jangan mulai dari request produksi yang panjang. Kurangi sampai menemukan input terkecil yang masih memicu masalah. Tujuannya bukan sekadar mereproduksi 500, tetapi menemukan pasangan input minimal dan AST salah minimal.
Langkah praktis
- Ambil satu request gagal dari log dengan request ID.
- Jalankan parser secara lokal atau di REPL dengan input asli.
- Buang bagian ekspresi satu per satu sambil membandingkan AST.
- Temukan titik saat parse berubah dari benar menjadi salah.
- Simpan sebagai fixture regresi.
Contoh reduksi
Input produksi:
(status = 'active' OR role = 'admin') AND deleted = false AND NOT suspended = trueSetelah direduksi, mungkin ternyata bug sudah muncul pada:
role = 'admin' AND NOT suspended = trueAtau bahkan lebih kecil:
NOT a = bIni petunjuk penting. Banyak parser salah menangani apakah NOT a = b berarti:
NOT (a = b), atau(NOT a) = b
Jika grammar tidak melarang bentuk kedua di level struktur, AST rusak bisa lolos.
Contoh pseudo-code backend yang bermasalah
Berikut pseudo-code parser combinator yang terlihat rapi tetapi salah secara desain:
parseExpr() = choice(
parseBinary(parseExpr, "OR", parseExpr),
parseBinary(parseExpr, "AND", parseExpr),
parseUnary("NOT", parseExpr),
parseParen(parseExpr),
parsePredicate()
)Masalahnya ada dua:
- Left recursion/rekursi tidak terstruktur pada banyak implementasi combinator.
- Tidak ada layer prioritas operator, sehingga parser tidak tahu bahwa
NOTharus mengikat lebih kuat daripada comparison, danANDlebih kuat daripadaOR.
Versi yang lebih aman memecah grammar per level prioritas:
parseExpr() = parseOr()
parseOr() = chainLeft1(parseAnd(), token("OR"), makeBinary("OR"))
parseAnd() = chainLeft1(parseNot(), token("AND"), makeBinary("AND"))
parseNot() = choice(
map(seq(token("NOT"), parseNot()), makeNot),
parsePrimary()
)
parsePrimary() = choice(
parseParen(parseExpr),
parsePredicate()
)Pendekatan ini bekerja karena grammar menjadi tidak ambigu secara operasional. Setiap level hanya bertanggung jawab pada satu kelas operator. Hasil parse juga lebih mudah dinormalisasi karena struktur AST sudah mengikuti prioritas bahasa.
Mengapa konteks typed/algebraic parsing relevan
Pendekatan typed/algebraic parsing menekankan bahwa parser tidak hanya menghasilkan “sesuatu yang berhasil diparse”, tetapi nilai terstruktur dengan invariant yang lebih kuat. Dalam konteks backend, ide praktisnya adalah: buat representasi AST yang menyulitkan parser menghasilkan bentuk ilegal.
Anda tidak perlu menerapkan teori penuh dari paper akademik untuk mendapat manfaatnya. Bahkan dengan pseudo-type sederhana, Anda bisa memisahkan node yang memang boolean expression dari node predicate atau literal.
Memperkuat desain AST dengan constraint tipe atau validasi struktur
Kalau bahasa dan codebase memungkinkan, pisahkan tipe AST menurut kategori semantik. Jangan semua node masuk ke satu enum besar tanpa batasan.
Contoh AST yang terlalu longgar
Node =
| Binary(op, Node, Node)
| Unary(op, Node)
| Identifier(name)
| Literal(value)
| Predicate(field, op, value)Struktur ini membolehkan bentuk absurd seperti Binary(AND, Identifier("x"), Literal(1)). Secara parser mungkin “sah”, tetapi semantik pasti salah.
Contoh AST yang lebih ketat
BoolExpr =
| And(BoolExpr, BoolExpr)
| Or(BoolExpr, BoolExpr)
| Not(BoolExpr)
| Comparison(FieldRef, CompareOp, Value)
FieldRef = Field(name)
Value = StringValue(text) | BoolValue(b) | NumberValue(n)Dengan desain ini, parser tidak bisa sembarangan menaruh Identifier mentah di posisi ekspresi boolean final. Jika implementasi bahasa mendukung type system lebih kuat, ini bisa dipaksa di compile-time. Jika tidak, minimal buat validator struktur sesudah parse dan sebelum eksekusi.
Validator struktur sebagai sabuk pengaman
validateBoolExpr(node):
match node:
case And(left, right):
validateBoolExpr(left)
validateBoolExpr(right)
case Or(left, right):
validateBoolExpr(left)
validateBoolExpr(right)
case Not(inner):
validateBoolExpr(inner)
case Comparison(field, op, value):
validateField(field)
validateValue(value)
else:
error("invalid boolean AST node")Validator ini bukan pengganti parser yang benar. Namun di sistem produksi, validator berguna untuk mengubah bug internal yang tadinya menjadi 500 tak terduga menjadi error yang lebih cepat terdeteksi, lebih mudah dilog, dan tidak menyentuh query layer.
Jika parser belum sepenuhnya bisa diperketat, tambahkan fase AST validation eksplisit dan perlakukan kegagalannya sebagai bug parser yang terukur, bukan exception liar di tahap akhir.
Perbaikan bertahap di sistem yang sudah berjalan
Dalam backend aktif, jangan langsung merombak seluruh parser jika risikonya tinggi. Lakukan bertahap.
1. Tambahkan observability sebelum refactor besar
- Log input ter-normalisasi.
- Log AST hasil parse.
- Pisahkan metrik parse error, validation error, execution error.
- Tambahkan sampling untuk payload bermasalah.
Ini memberi baseline sebelum perubahan.
2. Kunci perilaku saat ini dengan golden test
Sebelum mengubah grammar, simpan contoh input yang saat ini valid dan hasil AST/semantik yang diharapkan. Ini mencegah perbaikan satu bug merusak ekspresi lain yang sudah benar.
3. Pecah grammar berdasarkan prioritas operator
Ini biasanya perbaikan inti. Jangan andalkan choice datar untuk operator campuran. Gunakan level seperti or > and > not > primary sesuai kebutuhan bahasa.
4. Tambahkan validasi struktur AST
Jika ada bug residu, ia tertangkap lebih awal dan lebih dekat ke sumbernya.
5. Ubah respons untuk kegagalan parse/validasi
Input yang tidak bisa dipahami atau tidak membentuk struktur valid seharusnya menjadi 400 Bad Request, bukan 500. Status 500 hanya untuk kegagalan server yang benar-benar tidak diharapkan.
6. Deploy bertahap dan bandingkan error rate
Gunakan canary atau subset trafik jika memungkinkan. Fokus pada distribusi input yang dulu bermasalah.
Contoh tes regresi yang relevan
Tes parser yang baik tidak berhenti pada “berhasil parse”. Berikut kategori tes yang penting.
Tes prioritas operator
input: "a OR b AND c"
expect_ast: Or(a, And(b, c))Tes unary operator
input: "NOT a = b"
expect_ast: Not(Comparison(a, Eq, b))Tes grouping eksplisit
input: "(a OR b) AND c"
expect_ast: And(Or(a, b), c)Tes invariant struktur
for each parsed_ast:
validateBoolExpr(parsed_ast) must succeedTes round-trip bila ada pretty-printer
parse(render(parse(input)))) == parse(input)Round-trip test membantu mendeteksi AST ambigu atau non-kanonik. Jika parse lalu render menghasilkan bentuk yang tidak stabil, biasanya ada masalah desain grammar atau normalisasi.
Property-based test untuk input kombinatorial
Jika toolchain mendukung, buat generator ekspresi valid dalam ukuran kecil-menengah. Sasaran utamanya bukan performa, tetapi menemukan kombinasi operator yang tidak terpikir dalam unit test manual.
Minimal properti yang bisa diuji:
- Parser tidak melempar exception liar untuk input valid yang dibangkitkan generator.
- AST hasil parse selalu lolos validator struktur.
- Input dengan tanda kurung eksplisit mempertahankan semantik setelah parse.
Tips debugging untuk parser combinator di backend
- Waspadai urutan alternatif. Pada banyak combinator, cabang yang dicoba lebih dulu bisa “memakan” input sebagian lalu menghasilkan parse yang salah atau error yang menyesatkan.
- Bedakan backtracking yang diharapkan dan berlebihan. Backtracking yang terlalu longgar bisa membuat parser tampak berhasil padahal memilih jalur grammar yang salah.
- Log posisi token saat rule masuk/keluar. Ini sangat membantu ketika parser sukses, tetapi sukses pada rule yang tidak seharusnya.
- Jangan campur parsing dan validasi semantik terlalu dini. Jika keduanya bercampur, sulit menentukan apakah bug ada di grammar atau di evaluator.
- Normalisasi AST setelah parse bila perlu. Namun normalisasi tidak boleh menjadi tempat menyembunyikan grammar ambigu yang seharusnya diperbaiki di sumbernya.
Checklist pencegahan agar 500 intermiten tidak terulang
- Tentukan grammar tertulis untuk operator, prioritas, dan asosiasi.
- Pisahkan level parser menurut prioritas, jangan gunakan satu rule rekursif datar.
- Desain AST yang lebih ketat secara tipe atau struktur.
- Tambahkan fase validasi AST sebelum query execution.
- Bedakan 400 parse/validation error dari 500 internal error.
- Log input, AST, dan kategori error dengan request ID.
- Simpan minim reproduksi dari setiap bug produksi sebagai fixture tes.
- Tambahkan tes untuk kombinasi operator, unary, dan grouping.
- Pertimbangkan property-based test untuk eksplorasi kasus tepi.
- Tinjau kembali parser combinator yang menggunakan backtracking agresif tanpa kontrol.
Penutup
Ambiguitas grammar yang memicu 500 intermiten bukan sekadar masalah parsing, tetapi masalah kontrak antar-lapisan backend. Parser mengklaim sukses, AST ternyata tidak memenuhi invariant, lalu exception meledak di tahap yang lebih jauh dari sumber bug.
Pendekatan paling praktis adalah menggabungkan tiga hal: grammar yang eksplisit per level prioritas, AST yang lebih ketat atau tervalidasi, dan observability yang cukup untuk mengaitkan input dengan bentuk parse. Dengan itu, proses debug parser API berubah dari tebakan acak menjadi investigasi yang bisa direproduksi, diuji, dan dicegah agar tidak kembali di produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!