Membangun error response yang konsisten: HTTPException untuk error terstruktur, custom exception handler untuk seluruh aplikasi, handling error validasi Pydantic, dan structured logging JSON untuk observability produksi.

Aplikasi yang sehat bukan aplikasi yang tidak pernah error — melainkan yang error-nya terstruktur dan tercatat. Setelah di episode 16 konfigurasi sudah terpusat, sekarang kita bereskan dua hal yang menentukan kualitas hidup developer di produksi: bagaimana API mengembalikan error, dan bagaimana aplikasi bercerita lewat log.
Mengapa episode ini penting? Error response yang tidak konsisten memaksa klien menulis banyak percabangan; error response yang bocor (menampilkan traceback internal) membocorkan detail sistem. Dan tanpa logging yang baik, insiden produksi berubah menjadi sesi menebak-nebak. Episode ini membangun fondasi observability sejak awal.
Kita sudah memakai HTTPException sejak episode 9 — sekarang kita bentuk lengkap dan konsisten:
from fastapi import FastAPI, HTTPException
app = FastAPI()
class NotFoundError(HTTPException):
def __init__(self, resource: str) -> None:
super().__init__(
status_code=404,
detail=f"{resource} tidak ditemukan",
)
@app.get("/items/{item_id}")
def read_item(item_id: int) -> dict:
if item_id > 100:
raise NotFoundError("Item")
return {"item_id": item_id}Membungkus HTTPException dalam subclass membuat error domain — NotFoundError, UnauthorizedError — dipakai ulang dan tidak terulang-ulang menulis status + detail.
Tip
Keuntungan HTTPException: FastAPI mendokumentasikannya otomatis di OpenAPI (episode 21). Error yang dideklarasikan dengan jelas membuat klien tahu persis status apa yang harus mereka tangani.
Tidak semua error berupa HTTPException — kadang muncul ValueError, atau error dari library. FastAPI memberi kita @app.exception_handler untuk mengubah error apa pun menjadi response terstruktur:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(ValueError)
async def value_error_handler(
request: Request, exc: ValueError
) -> JSONResponse:
return JSONResponse(
status_code=400,
content={"detail": str(exc), "code": "BAD_REQUEST"},
)
@app.get("/divide/{a}/{b}")
def divide(a: int, b: int) -> dict[str, float]:
if b == 0:
raise ValueError("pembagian dengan nol")
return {"result": a / b}Sekarang ValueError apa pun di aplikasi berubah menjadi response 400 dengan format {"detail": ..., "code": ...}. Error handler adalah titik terpusat untuk memformat error — klien selalu melihat bentuk yang sama.
Agar klien mudah memproses, tetapkan satu format error di seluruh API. Pola yang banyak dipakai:
{
"detail": "Item tidak ditemukan",
"code": "NOT_FOUND",
"status": 404
}Untuk mewujudkannya di seluruh aplikasi, daftarkan handler bawaan juga — misalnya mengubah error validasi Pydantic yang default-nya multi-level:
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
errors = []
for err in exc.errors():
errors.append({
"field": ".".join(str(p) for p in err["loc"]),
"message": err["msg"],
"type": err["type"],
})
return JSONResponse(
status_code=422,
content={"detail": errors, "code": "VALIDATION_ERROR"},
)Alih-alih menumpuk loc bersarang, klien menerima daftar field/message yang datar — jauh lebih mudah di-render di form frontend.
Log teks polos sulit diparsing mesin. Structured logging (JSON per baris) membuat log bisa di-index oleh tool observability seperti Loki, Elasticsearch, atau Grafana:
import json
import logging
from datetime import datetime, timezone
logger = logging.getLogger("fastapi-lab")
def log_json(level: str, event: str, **fields: object) -> None:
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": level,
"event": event,
**fields,
}
logger.log(getattr(logging, level), json.dumps(record))Pemakaiannya:
@app.post("/items/")
def create_item(name: str) -> dict:
item_id = 1
log_json(
"INFO",
"item.created",
item_id=item_id,
name=name,
)
return {"item_id": item_id}Konvensi event ber-titik (item.created) memungkinkan agregasi per event di dashboard.
Warning
Jangan pernah memasukkan data sensitif ke log — password, token, dan header Authorization harus di-mask. Password yang tertulis di log adalah kebocoran yang menunggu terjadi. Log untuk observability, bukan untuk forensik data.
Kadang error muncul di luar handler — misalnya di middleware atau library. Handler Exception global adalah jaring pengaman terakhir:
import logging
@app.exception_handler(Exception)
async def unhandled_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
logging.exception("unhandled error", exc_info=exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "code": "INTERNAL"},
)Response ke klien tidak membocorkan detail (hanya Internal server error), tapi log lengkap (via logging.exception) tetap tercatat untuk debugging. Jangan pernah mengembalikan traceback ke klien.
Important
Jangan terlalu sering menangkap Exception global untuk hal yang bisa dicegah dengan validasi. Jaring ini untuk error tak terduga saja — kode yang jelas (validasi Pydantic, HTTPException) tetap lebih baik daripada jaring yang menutupi semuanya.
| Pitfall | Solusi |
|---|---|
| Traceback ke klien | Handler global → 500 + detail, log penuh di server |
| Error response tidak konsisten | Satu format via handler terpusat |
| Log tanpa timestamp/level | Structured JSON |
| Log berisi password/token | Mask selalu |
| Error 500 yang tidak tercatat | logging.exception di handler global |
Inti yang harus dibawa pulang:
HTTPException untuk error yang diketahui; subclass untuk error domain.@app.exception_handler memformat error apa pun jadi response konsisten.field/message yang ramah klien.Di episode 18 selanjutnya kita akan membahas security best practices — pengerasan API dari SQLi, XSS, CSRF, security headers, hingga dependency security. Semua yang kalian pelajari dari episode 5 sampai 17 sekarang dirakit menjadi pertahanan berlapis!