Bug pada backend hasil vibecoding sering bukan berupa aplikasi yang langsung crash. Masalah yang lebih sulit adalah exception database ditangkap terlalu dini, lalu endpoint mengembalikan fallback seperti [] dengan status HTTP 200 OK. Dari sisi klien, respons terlihat valid, tetapi data tampak hilang dan tidak ada sinyal jelas bahwa sistem sedang gagal.

Solusinya adalah membedakan data kosong yang valid dari kegagalan mengambil data. Exception harus dicatat dengan konteks yang cukup, diteruskan ke error-handling middleware, dan diterjemahkan menjadi status HTTP yang sesuai—biasanya 500 atau 503, bukan 200. Artikel ini menggunakan studi kasus Express/Node.js untuk menunjukkan proses diagnosis sampai regression test.

Studi kasus: data hilang tanpa error

Misalkan tersedia endpoint untuk mengambil daftar pesanan:

app.get('/api/orders', async (req, res) => {
  try {
    const orders = await orderRepository.findByUserId(req.user.id);
    res.json(orders);
  } catch (error) {
    console.error(error);
    res.status(200).json([]);
  }
});

Implementasi tersebut mungkin terlihat aman karena klien selalu menerima JSON. Namun, respons [] memiliki dua arti yang berbeda:

  • Pengguna memang belum memiliki pesanan.
  • Query gagal, database tidak tersedia, kredensial salah, atau koneksi mengalami timeout.

Ketika kedua kondisi tersebut direpresentasikan sebagai 200 OK dan array kosong, klien tidak dapat membedakannya. UI dapat menampilkan pesan “belum ada data”, sementara tim operasional kehilangan sinyal bahwa database sedang bermasalah.

Gejala dan cara mereproduksi bug

Gejala yang biasanya terlihat

  • Endpoint selalu membalas 200 OK, bahkan ketika database sedang dihentikan.
  • Respons memiliki bentuk JSON yang valid, tetapi isinya kosong atau menggunakan nilai default.
  • Log hanya berisi pesan generik seperti database error tanpa endpoint, user, atau request ID.
  • Dashboard frontend menampilkan keadaan kosong, bukan pesan gangguan layanan.
  • Monitoring berbasis status HTTP tidak mendeteksi kegagalan karena semua request dianggap sukses.

Reproduksi secara terkontrol

Jangan menguji dengan mematikan database produksi. Gunakan database pengujian atau mock repository yang sengaja melempar exception:

const failingRepository = {
  findByUserId: async () => {
    throw new Error('database connection refused');
  }
};

Pasang repository tersebut pada aplikasi pengujian, lalu panggil endpoint:

curl -i http://localhost:3000/api/orders

Jika hasilnya tetap seperti berikut, bug terkonfirmasi:

HTTP/1.1 200 OK
Content-Type: application/json

[]

Uji yang sama sebaiknya dilakukan dengan beberapa jenis kegagalan: timeout, error autentikasi database, koneksi terputus, dan exception dari kode pemetaan hasil query. Tujuannya memastikan semua jalur kegagalan tidak diam-diam berubah menjadi data kosong.

Strategi membaca log dan trace

Mulai dari gejala, bukan asumsi

Periksa urutan berikut:

  1. Catat URL, method, waktu kejadian, status HTTP, dan durasi request.
  2. Bandingkan jumlah respons 200 dengan metrik error dari database atau connection pool.
  3. Cari request ID atau trace ID yang sama di log aplikasi, repository, dan database proxy.
  4. Periksa apakah stack trace berhenti di handler endpoint karena exception sudah ditangkap di sana.
  5. Bandingkan respons dari pengguna yang tidak memiliki data dengan respons ketika database gagal.

Jika tidak ada log sama sekali, kemungkinan error ditelan oleh blok catch, promise tidak diteruskan, atau logger gagal menulis. Jika ada log tetapi hanya berisi Error, informasi penting seperti operasi database dan identitas request mungkin hilang.

Gunakan correlation ID

Request ID membantu menghubungkan satu permintaan dari HTTP server ke service, repository, dan log database. Middleware sederhana dapat membuat ID jika klien tidak mengirimkannya:

import crypto from 'node:crypto';

app.use((req, res, next) => {
  const requestId = req.get('x-request-id') || crypto.randomUUID();
  req.requestId = requestId;
  res.setHeader('x-request-id', requestId);
  next();
});

Dalam sistem yang sudah memakai distributed tracing, gunakan trace ID dari sistem tracing tersebut dan jangan membuat identitas kedua tanpa alasan. Yang penting, ID yang dikembalikan ke klien sama dengan ID yang dapat dicari di log server.

Root cause: fallback mengubah kegagalan menjadi keberhasilan

Akar masalah utama bukan sekadar adanya try/catch, melainkan keputusan untuk mengembalikan fallback yang memiliki arti bisnis berbeda dari error. Pola ini sering muncul ketika kode dibuat cepat: tujuan awalnya mencegah aplikasi crash, tetapi tidak ada kontrak yang jelas untuk kondisi database gagal.

Error middleware tidak akan membantu jika exception sudah diubah menjadi respons sukses di handler. Pada contoh awal, eksekusi berhenti setelah res.status(200).json([]). Tidak ada error yang diteruskan ke middleware berikutnya.

Kesalahan lain yang umum adalah middleware error dipasang sebelum route:

// Salah: dipanggil sebelum route yang menghasilkan error
app.use(errorHandler);
app.use('/api', apiRouter);

Middleware error Express harus dipasang setelah seluruh route dan middleware yang mungkin menghasilkan error:

app.use('/api', apiRouter);
app.use(errorHandler);

Untuk handler asynchronous, pastikan exception ditangani sesuai pola yang digunakan aplikasi. Salah satu pola yang eksplisit adalah membungkus handler dan meneruskan error ke next:

const asyncHandler = (handler) => (req, res, next) => {
  Promise.resolve(handler(req, res, next)).catch(next);
};

Perbaikan kode yang aman

Bedakan data kosong dan kegagalan dependensi

Repository boleh mengembalikan array kosong jika query berhasil dan memang tidak ada baris. Namun, repository harus melempar exception ketika query gagal. Handler tidak boleh mengubah exception tersebut menjadi array kosong.

app.get('/api/orders', asyncHandler(async (req, res) => {
  const orders = await orderRepository.findByUserId(req.user.id);
  res.status(200).json(orders);
}));

Dengan pola ini, hasil query kosong tetap menjadi 200 OK, sedangkan exception mengalir ke error middleware.

Error middleware dengan status yang tepat

function errorHandler(err, req, res, next) {
  const requestId = req.requestId;

  req.logger?.error({
    err,
    requestId,
    method: req.method,
    path: req.originalUrl
  }, 'request failed');

  if (res.headersSent) {
    return next(err);
  }

  const status = err.statusCode || 500;
  const publicMessage = status >= 500
    ? 'Terjadi gangguan pada layanan.'
    : err.message;

  res.status(status).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: publicMessage,
      requestId
    }
  });
}

Untuk kegagalan sementara pada database atau service internal, 503 Service Unavailable dapat lebih informatif daripada 500 Internal Server Error, terutama jika aplikasi memang tidak dapat melayani request karena dependensi tidak tersedia. Pilihan ini harus mengikuti kontrak API dan klasifikasi error aplikasi.

  • 200 OK: request berhasil dan data kosong memang valid.
  • 400 Bad Request: input klien tidak valid.
  • 401 Unauthorized atau 403 Forbidden: masalah autentikasi atau otorisasi.
  • 500 Internal Server Error: kesalahan tak terduga di sisi server.
  • 503 Service Unavailable: dependensi penting sementara tidak tersedia.

Jangan mengirim stack trace, detail query, hostname database, atau kredensial ke klien. Detail tersebut hanya untuk log internal yang memiliki kontrol akses.

Structured logging untuk mempercepat diagnosis

Log teks seperti failed to load orders sulit difilter dan dikorelasikan. Structured logging menyimpan event sebagai objek dengan field yang konsisten. Contoh berikut memakai antarmuka logger generik; implementasinya dapat menggunakan library logging yang dipilih proyek.

req.logger.error({
  event: 'orders.list_failed',
  requestId: req.requestId,
  userId: req.user.id,
  operation: 'orderRepository.findByUserId',
  errorName: err.name,
  errorCode: err.code,
  durationMs: Date.now() - startedAt,
  err: {
    message: err.message,
    stack: err.stack
  }
}, 'failed to load orders');

Field yang berguna biasanya mencakup nama event, request ID, route, operasi, durasi, kode error, dan stack trace. Hindari memasukkan token, password, data kartu, atau informasi pribadi yang tidak diperlukan. Jika user ID sensitif, gunakan identifier internal atau masking sesuai kebijakan privasi.

Tambahkan metrik yang tidak bergantung hanya pada status respons. Contohnya jumlah kegagalan query berdasarkan operasi, durasi query, timeout, dan jumlah respons 5xx. Dengan begitu, perubahan status dari 200 palsu menjadi 503 juga dapat terlihat dalam monitoring.

Regression test: pastikan error tidak kembali ditelan

Regression test harus memverifikasi dua jalur yang berbeda: query berhasil tanpa hasil, dan query gagal. Keduanya tidak boleh menghasilkan respons yang sama.

describe('GET /api/orders', () => {
  it('returns 200 with an empty list when the query succeeds', async () => {
    repository.findByUserId.mockResolvedValue([]);

    const response = await request(app)
      .get('/api/orders')
      .set('x-user-id', 'user-1');

    expect(response.status).toBe(200);
    expect(response.body).toEqual([]);
  });

  it('returns a server error when the database query fails', async () => {
    repository.findByUserId.mockRejectedValue(
      new Error('database connection refused')
    );

    const response = await request(app)
      .get('/api/orders')
      .set('x-user-id', 'user-1');

    expect([500, 503]).toContain(response.status);
    expect(response.body.error).toBeDefined();
    expect(response.body).not.toHaveProperty('orders', []);
  });
});

Contoh tersebut menggunakan gaya API pengujian yang umum pada ekosistem Node.js. Detail setup dapat berbeda, tetapi prinsipnya tetap: mock harus menolak promise, lalu test harus memeriksa status HTTP, bentuk error, dan keberadaan request ID jika itu bagian dari kontrak API.

Tambahkan pula test untuk error middleware secara langsung, terutama untuk kondisi res.headersSent, error yang memiliki status tertentu, serta sanitasi pesan internal. Pada level integrasi, jalankan pengujian dengan database pengujian agar kegagalan koneksi dan mapping error tidak hanya diuji melalui mock.

Checklist sebelum menggabungkan perbaikan

  • Apakah array kosong hanya dikembalikan setelah query berhasil?
  • Apakah semua handler asynchronous meneruskan rejection ke error middleware?
  • Apakah error middleware diletakkan setelah route?
  • Apakah status 500 atau 503 dipilih berdasarkan jenis kegagalan?
  • Apakah respons publik bebas dari stack trace dan detail database?
  • Apakah log memiliki request ID, operasi, kode error, dan stack trace?
  • Apakah ada regression test yang membedakan “tidak ada data” dari “database gagal”?
  • Apakah monitoring mendeteksi kegagalan dependensi, bukan hanya error HTTP?

Pelajaran dari konteks “Now What?”

Konteks tulisan “Now What?” relevan sebagai pengingat bahwa setelah kode cepat terbentuk, pekerjaan engineering belum selesai. Langkah berikutnya adalah memahami apa yang sebenarnya dibuat, memeriksa asumsi, menjalankannya dalam kondisi gagal, dan membangun umpan balik yang dapat dipercaya.

Dalam kasus ini, pendekatan tersebut berarti tidak berhenti pada pertanyaan “endpoint mengembalikan JSON atau tidak”. Pertanyaan yang lebih penting adalah: apakah kontrak respons membedakan keadaan normal dan keadaan gagal, apakah log menjelaskan kejadian, dan apakah test akan menangkap regresi ketika seseorang menambahkan fallback baru?

Kode yang terlihat berjalan belum tentu dapat dipercaya. Kepercayaan datang dari kontrak error yang jelas, observabilitas, dan pengujian terhadap kondisi kegagalan.

Vibecoding dapat mempercepat pembuatan kerangka endpoint, tetapi keputusan tentang semantik status HTTP, keamanan log, propagasi exception, dan regression test tetap perlu ditinjau secara sadar. Untuk bug seperti ini, perbaikan terbaik bukan sekadar mengganti 200 menjadi 500; perbaikannya adalah memulihkan sinyal kegagalan dari database sampai ke klien dan sistem monitoring.