Status code adalah kontrak tersingkat antara API dan klien: angka pertama yang dibaca setiap HTTP client bahkan sebelum menyentuh body. Klien mengandalkan 201 untuk tahu resource baru lahir, 429 untuk memutuskan kapan mencoba ulang, dan 5xx untuk membedakan salahnya klien dari salahnya server. Sayangnya, banyak API memilih status secara asal — semua kesalahan dibalas 400 atau, lebih buruk, 200 dengan pesan error di dalam body. Pada Chapter ini kita susun kebijakan pemilihan status per operasi dan bangun renderer error berbentuk tetap, sehingga setiap kegagalan API Anda jujur, informatif, dan seragam.
Status Code sebagai Kontrak API #
Teori lengkap keluarga status 1xx sampai 5xx sudah kita bahas pada Chapter HTTP Status Codes
di Section Web Fundamentals. Di sini fokus kita adalah praktik pemilihannya untuk REST API. Prinsip dasarnya dua: sukses diungkap sepresisi mungkin (201, 204 lebih informatif daripada 200 untuk segalanya), dan jenis kegagalan harus memandu tindakan klien (ulangi? perbaiki input? hubungi admin?). Tabel berikut adalah kebijakan yang akan kita terapkan sepanjang chapter:
| Status | Nama | Kapan dipakai |
|---|---|---|
200 OK | Sukses umum | GET, PUT/PATCH yang mengembalikan body |
201 Created | Resource baru dibuat | POST yang berhasil, wajib disertai Location |
204 No Content | Sukses tanpa body | DELETE yang berhasil |
400 Bad Request | Request rusak | Body bukan JSON valid, parameter tidak bisa diparse |
401 Unauthorized | Tidak terautentikasi | Token hilang atau tidak valid |
403 Forbidden | Tidak berhak | Teridentifikasi tapi tidak punya akses |
404 Not Found | Tidak dikenal | Path atau resource tidak ada |
405 Method Not Allowed | Method salah | Path ada, method tidak didukung |
409 Conflict | Data bentrok | Email duplikat, versi tidak sinkron |
422 Unprocessable Entity | Validasi gagal | Struktur valid, isi melanggar aturan bisnis |
429 Too Many Requests | Kena batas laju | Rate limit terlampaui, sertai Retry-After |
500 Internal Server Error | Salah server | Bug atau kegagalan infrastruktur |
Pembedaan 400 versus 422 layak ditegaskan karena paling sering tertukar: 400 berarti request tidak bisa diparse sama sekali (JSON rusak, Content-Type salah), sedangkan 422 berarti request terbaca dengan baik tetapi isinya melanggar aturan bisnis — kelanjutan langsung dari batas parsing-validasi yang kita tetapkan di Chapter Request Parsing
.
Renderer Error yang Konsisten #
Bentuk error yang berbeda-beda antar endpoint adalah beban klien. Solusinya: satu fungsi renderer yang selalu menghasilkan objek error berisi code (identitas mesin yang stabil) dan message (kalimat manusia), dengan field details opsional untuk rincian per-field:
<?php
function renderError(int $status, string $code, string $message, array $details = []): array
{
$error = ["code" => $code, "message" => $message];
if ($details !== []) {
$error["details"] = $details;
}
return [
$status,
["Content-Type: application/json; charset=utf-8"],
json_encode(
["error" => $error],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
];
}
[$status, $headers, $body] = renderError(
422,
"VALIDATION_ERROR",
"Data task tidak valid",
[
["field" => "title", "issue" => "wajib diisi"],
["field" => "dueDate", "issue" => "harus berupa tanggal"],
]
);
echo "HTTP " . $status . "\n";
echo implode("\n", $headers) . "\n";
echo $body . "\n";Output yang Dihasilkan:
HTTP 422
Content-Type: application/json; charset=utf-8
{"error":{"code":"VALIDATION_ERROR","message":"Data task tidak valid","details":[{"field":"title","issue":"wajib diisi"},{"field":"dueDate","issue":"harus berupa tanggal"}]}}Renderer ini sekali lagi mengembalikan triple [status, headers, body] — bukan header() dan echo — sehingga bisa dites di CLI dan dipakai handler mana pun. Parameter $details diberi nilai default array kosong dan hanya muncul di JSON bila terisi, sehingga error sederhana tidak membawa details: [] yang tidak bermakna. Setiap handler kini cukup memanggil satu fungsi untuk kegagalan apa pun, dan klien Anda cukup menulis satu cabang if (isset($payload["error"])) di sisi penerima.
201 Created dan Header Location #
Operasi pembuatan resource layak mendapat status yang lebih presisi daripada 200: 201 Created, disertai header Location yang menunjuk URL resource baru. Sementara itu, penghapusan yang berhasil paling pas diwakili 204 No Content — sukses yang tidak punya apa-apa untuk dikatakan, sehingga body-nya memang kosong:
<?php
function createTask(array $request): array
{
$task = [
"id" => 42,
"title" => $request["body"]["title"],
"done" => false,
];
return [
201,
[
"Content-Type: application/json; charset=utf-8",
"Location: /tasks/42",
],
json_encode(["data" => $task], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
];
}
function deleteTask(array $request): array
{
return [204, [], ""];
}
[$status, $headers, $body] = createTask(["body" => ["title" => "Belajar status code"]]);
echo "POST /tasks => " . $status . "\n";
echo implode("\n", $headers) . "\n";
echo $body . "\n\n";
[$status, $headers, $body] = deleteTask(["params" => ["id" => 42]]);
echo "DELETE /tasks/42 => " . $status . "\n";
echo "headers: " . ($headers === [] ? "(tidak ada)" : implode(", ", $headers)) . "\n";
echo "body: '" . $body . "'\n";Output yang Dihasilkan:
POST /tasks => 201
Content-Type: application/json; charset=utf-8
Location: /tasks/42
{"data":{"id":42,"title":"Belajar status code","done":false}}
DELETE /tasks/42 => 204
headers: (tidak ada)
body: ''Respons 201 menunjukkan kontrak lengkap: status menyatakan “resource lahir”, Location memberi tahu alamatnya, dan body memuat datanya — klien bisa langsung dipakai tanpa request tambahan. Respons 204 kebalikannya: header dan body kosong total karena tidak ada informasi baru yang perlu disampaikan. Kombinasi 200 plus body {"success": true} adalah kebiasaan lama era XML-RPC yang sebaiknya ditinggalkan; status line sudah menjadi tempat informasi itu sejak semula.
Membedakan 401 dan 403 #
Dua status ini paling sering dipakai tertukar karena namanya mirip. 401 Unauthorized sebenarnya berarti unauthenticated: klien belum membuktikan identitasnya sama sekali. 403 Forbidden berarti identitas sudah jelas, tetapi pemiliknya tidak berhak mengakses resource ini. Handler berikut menunjukkan urutan pemeriksaan yang benar — autentikasi dulu, baru otorisasi:
<?php
function viewTask(array $request): array
{
$user = $request["user"] ?? null;
if ($user === null) {
return [
401,
["Content-Type: application/json; charset=utf-8", "WWW-Authenticate: Bearer"],
'{"error":{"code":"UNAUTHENTICATED","message":"Token akses hilang atau tidak valid"}}',
];
}
$task = ["id" => 5, "ownerId" => 7, "title" => "Laporan keuangan Q1"];
if ($user["id"] !== $task["ownerId"]) {
return [
403,
["Content-Type: application/json; charset=utf-8"],
'{"error":{"code":"FORBIDDEN","message":"Anda tidak berhak mengakses task ini"}}',
];
}
return [
200,
["Content-Type: application/json; charset=utf-8"],
json_encode(["data" => $task]),
];
}
$requests = [
["user" => null, "label" => "tanpa token"],
["user" => ["id" => 3], "label" => "user lain"],
["user" => ["id" => 7], "label" => "pemilik task"],
];
foreach ($requests as $request) {
[$status, $headers, $body] = viewTask($request);
echo $request["label"] . " => " . $status . " " . $body . "\n";
}Output yang Dihasilkan:
tanpa token => 401 {"error":{"code":"UNAUTHENTICATED","message":"Token akses hilang atau tidak valid"}}
user lain => 403 {"error":{"code":"FORBIDDEN","message":"Anda tidak berhak mengakses task ini"}}
pemilik task => 200 {"data":{"id":5,"ownerId":7,"title":"Laporan keuangan Q1"}}Tiga request tiruan memetakan tiga nasib: tanpa token berujung 401 (dengan header WWW-Authenticate: Bearer yang memberi tahu klien cara mengautentikasi ulang), user yang salah orang berujung 403, dan pemilik task menerima datanya dengan 200. Header WWW-Authenticate pada 401 bukan hiasan — spesifikasi HTTP menganjurkannya agar klien tahu skema autentikasi yang diterima server. Untuk API berbasis token seperti ini, cukup nama skemanya saja. Perhatikan pula bahwa urutan cabang penting: memeriksa kepemilikan sebelum autentikasi bukan hanya salah secara logika, tetapi juga membocorkan informasi kepada pengguna anonim bahwa resource itu ada.
409 Conflict untuk Data yang Bentrok #
Ketika request valid secara struktur tetapi bentrok dengan data yang sudah ada — email terdaftar, username dipakai, versi resource tidak sinkron — status yang tepat adalah 409 Conflict. Contoh berikut menangkap pelanggaran constraint UNIQUE di SQLite dan menerjemahkannya menjadi error 409 berbentuk standar:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(
"CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE
)"
);
$pdo->exec("INSERT INTO users (email) VALUES ('[email protected]')");
function registerUser(PDO $pdo, array $request): array
{
$email = $request["body"]["email"] ?? "";
try {
$stmt = $pdo->prepare("INSERT INTO users (email) VALUES (:email)");
$stmt->execute([":email" => $email]);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), "UNIQUE constraint failed")) {
return [
409,
["Content-Type: application/json; charset=utf-8"],
'{"error":{"code":"EMAIL_ALREADY_USED","message":"Email sudah terdaftar"}}',
];
}
throw $e;
}
return [
201,
["Content-Type: application/json; charset=utf-8"],
'{"data":{"id":' . (int) $pdo->lastInsertId() . ',"email":"' . $email . '"}}',
];
}
$requests = [
["body" => ["email" => "[email protected]"], "label" => "email baru"],
["body" => ["email" => "[email protected]"], "label" => "email duplikat"],
];
foreach ($requests as $request) {
[$status, $headers, $body] = registerUser($pdo, $request);
echo $request["label"] . " => " . $status . " " . $body . "\n";
}Output yang Dihasilkan:
email baru => 201 {"data":{"id":2,"email":"[email protected]"}}
email duplikat => 409 {"error":{"code":"EMAIL_ALREADY_USED","message":"Email sudah terdaftar"}}Blok try/catch di sekitar INSERT adalah idiom penting: database adalah wasit terakhir keunikan data, karena dua request bersamaan bisa lolos pemeriksaan aplikasi namun tetap bentrok di level constraint. PDOException yang tertangkap diperiksa dulu penyebabnya — hanya pelanggaran UNIQUE yang diterjemahkan menjadi 409; exception lain dilempar ulang (throw $e) agar ditangani error handler global, bukan salah didiagnosis sebagai konflik. Pembedaan ini penting di lingkungan nyata di mana PDOException juga muncul untuk koneksi putus atau query salah ketik, yang semestinya berujung 500.
429 dan Header Retry-After #
Ketika klien melewati batas laju permintaan, balas 429 Too Many Requests disertai header Retry-After yang memberi tahu kapan boleh mencoba lagi. Tanpa header ini, klien hanya bisa menebak-nebak — dan menebak biasanya berarti menghujani server lebih cepat:
<?php
function rateLimitResponse(): array
{
return [
429,
[
"Content-Type: application/json; charset=utf-8",
"Retry-After: 60",
],
'{"error":{"code":"RATE_LIMITED","message":"Terlalu banyak permintaan, coba lagi nanti"}}',
];
}
[$status, $headers, $body] = rateLimitResponse();
echo "Status: " . $status . "\n";
echo implode("\n", $headers) . "\n";
echo $body . "\n";Output yang Dihasilkan:
Status: 429
Content-Type: application/json; charset=utf-8
Retry-After: 60
{"error":{"code":"RATE_LIMITED","message":"Terlalu banyak permintaan, coba lagi nanti"}}Nilai Retry-After: 60 menyatakan jeda dalam satuan detik; bentuk alternatifnya adalah tanggal HTTP lengkap. Klien yang terdidik akan membaca header ini dan menjadwalkan percobaan ulang, bukan langsung mengulang request. Perancangan mekanisme penghitung laju itu sendiri — penyimpanan counter, jendela waktu, pembatasan per API key — adalah topik tersendiri di Chapter Rate Limiting
; di sini kita hanya menetapkan bahwa saat batas terlampaui, kontrak responsnya adalah 429 plus Retry-After.
500 Tanpa Membocorkan Detail #
Status 500 menyatakan salah pihak server: bug di kode, query gagal, koneksi database putus. Kode di bawah ini membungkus handler dalam penjaga try/catch Throwable dan hanya memancarkan pesan generik, betapa pun detailnya kegagalan yang sebenarnya:
<?php
function runHandler(callable $handler, array $request): array
{
try {
return $handler($request);
} catch (Throwable $e) {
// detail kegagalan dicatat ke log server, bukan dikirim ke klien
return [
500,
["Content-Type: application/json; charset=utf-8"],
json_encode(
[
"error" => [
"code" => "INTERNAL_ERROR",
"message" => "Terjadi kesalahan internal",
],
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
];
}
}
$handlerYangGagal = function (array $request): array {
throw new RuntimeException(
"Koneksi database gagal: dsn=mysql://root:[email protected]"
);
};
[$status, $headers, $body] = runHandler($handlerYangGagal, ["path" => "/payments"]);
echo "Status: " . $status . "\n";
echo $body . "\n";Output yang Dihasilkan:
Status: 500
{"error":{"code":"INTERNAL_ERROR","message":"Terjadi kesalahan internal"}}Handler tiruan sengaja melempar RuntimeException yang pesannya memuat DSN beserta kata sandi. Output membuktikan tidak satu byte pun dari pesan itu sampai ke klien — yang keluar hanya pesan generik dengan kode INTERNAL_ERROR. Menangkap Throwable (bukan hanya Exception) penting karena PHP juga melempar Error seperti TypeError dan DivisionByZeroError. Tempat yang tepat untuk detail sebenarnya adalah log server: dalam implementasi nyata, blok catch ini memanggil error_log($e) atau logger terstruktur sebelum membalas, sehingga tim Anda bisa menyelidiki sementara klien tetap aman. Strategi penanganan exception berlapis yang lebih lengkap sudah dibahas di Section Error Handling
.
application/problem+json #
Sebagai penutup, patut diketahui bahwa internet punya standar terbuka untuk error HTTP bernama “Problem Details” (RFC 9457) dengan media type application/problem+json. Bentuknya memperkaya konvensi error kita dengan field baku yang saling terkait tautan dokumentasi:
{
"type": "https://example.com/errors/validation",
"title": "Data task tidak valid",
"status": 422,
"detail": "Field title wajib diisi",
"instance": "/tasks"
}Field type menunjuk dokumentasi jenis error, title dan detail memisahkan ringkasan dari rincian, status mengulang kode HTTP, dan instance mengidentifikasi request yang gagal. Standar ini paling terasa manfaatnya untuk API publik yang dikonsumsi pihak ketiga, karena klien bisa mengandalkan struktur yang sama lintas penyedia API. Untuk API internal, bentuk {error: {code, message, details}} yang kita bangun sepanjang chapter sudah memadai dan lebih sederhana; keduanya bahkan bisa berjalan beriringan — sebagian framework menyediakan keduanya lewat negosiasi Accept.
Ringkasan #
Status code yang presisi adalah dokumentasi termurah sebuah API: 201 plus Location untuk kelahiran resource, 204 untuk penghapusan senyap, 400 versus 422 untuk memisahkan request rusak dari isi yang melanggar aturan, 401 versus 403 untuk memisahkan identitas dari hak akses, 409 untuk bentrok data, 429 plus Retry-After untuk batas laju, dan 500 yang generik di luar tetapi jujur di log. Semua kegagalan mengalir lewat satu renderer berbentuk {error: {code, message, details?}} sehingga klien menangani error dengan satu pola. Dengan kontrak request dan respons yang kini lengkap, kita siap memperdalam lapisan yang paling banyak dipanggil di dunia nyata: validasi masukan, topik Chapter Request Validation
.