Exceptions, Failure Semantics, dan Defensive Python
Part 010 — Exceptions, Failure Semantics, dan Defensive Python
Membahas exception Python secara engineering-level: hierarchy, raise/catch, custom exception, exception chaining, failure taxonomy, defensive boundaries, logging, dan error semantics.
Part 010 — Exceptions, Failure Semantics, dan Defensive Python
1. Tujuan Part Ini
Program yang baik bukan hanya bekerja saat input benar. Program yang baik punya failure semantics yang jelas.
Dalam Python, failure terutama dimodelkan dengan exception.
Banyak bug production muncul bukan karena exception terjadi, tetapi karena exception didesain buruk:
- error ditelan;
- error terlalu umum;
- traceback hilang;
- domain error bercampur infrastructure error;
- caller harus menebak apakah return
Noneberarti not found atau system down; - API boundary membocorkan internal exception;
- retry dilakukan pada error yang tidak retryable;
- user mendapat pesan tidak jelas;
- log tidak cukup untuk diagnosis;
- cleanup tidak berjalan;
- partial side effect tidak dipikirkan.
Part ini membahas exception sebagai desain, bukan hanya syntax try/except.
Target setelah part ini:
- Memahami exception hierarchy.
- Menulis
raisedantry/exceptdengan benar. - Mendesain custom exception.
- Membedakan domain error dan infrastructure error.
- Memakai exception chaining.
- Menghindari bare except.
- Menjaga traceback.
- Memakai
elsedanfinallysecara tepat. - Mendesain error boundary.
- Menghubungkan failure semantics ke
case-tracker.
2. Failure Semantics
Failure semantics menjawab:
- Apa yang dianggap gagal?
- Siapa yang mendeteksi kegagalan?
- Siapa yang boleh memulihkan?
- Apa informasi yang dibawa error?
- Apakah error retryable?
- Apakah error harus terlihat oleh user?
- Apakah error harus dilog?
- Apakah state sudah berubah sebelum error?
- Apakah cleanup wajib?
- Apakah error domain atau infrastructure?
Contoh:
def get_case(path: Path, case_id: str) -> Case:
...
Jika case tidak ada, apa yang terjadi?
Opsi:
- return
None; - raise
CaseNotFoundError; - return
Result[Case, Error]style custom; - create default case;
- print error dan exit.
Untuk Python application code, opsi 2 sering jelas:
raise CaseNotFoundError(case_id)
Karena missing case adalah failure untuk get_case.
Namun untuk find_case, return None bisa masuk akal:
def find_case(cases: list[Case], case_id: str) -> Case | None:
...
Nama function dan failure contract harus selaras.
3. Exception Hierarchy Dasar
Exception Python punya hierarchy.
Beberapa yang sering ditemui:
| Exception | Kapan Muncul |
|---|---|
ValueError | Value valid type tetapi invalid value |
TypeError | Type object tidak sesuai operasi |
KeyError | Key dict tidak ditemukan |
IndexError | Index sequence tidak ada |
AttributeError | Attribute tidak ditemukan |
FileNotFoundError | File tidak ditemukan |
PermissionError | Permission OS gagal |
TimeoutError | Operasi timeout |
RuntimeError | Runtime failure umum |
NotImplementedError | Method belum diimplementasikan |
AssertionError | Assertion gagal |
Contoh:
int("abc")
Muncul:
ValueError
Contoh:
"hello" + 1
Muncul:
TypeError
Contoh:
data = {}
data["id"]
Muncul:
KeyError
4. Raise Exception
Gunakan raise untuk melaporkan error.
def validate_title(title: str) -> None:
if not title.strip():
raise ValueError("Case title cannot be empty")
Panggil:
validate_title(" ")
Exception menghentikan flow normal dan mencari handler yang sesuai.
4.1 Raise Exception Class vs Instance
Bisa:
raise ValueError
Lebih baik:
raise ValueError("Case title cannot be empty")
Pesan error penting untuk debugging.
5. Catch Exception
Gunakan try/except.
try:
validate_title(raw_title)
except ValueError as error:
print(f"Invalid input: {error}")
Catching harus dilakukan di layer yang bisa mengambil keputusan.
Domain layer biasanya raise. Boundary layer biasanya catch dan present.
6. Jangan Bare Except
Buruk:
try:
do_work()
except:
pass
Ini menangkap hampir semua hal, termasuk error yang seharusnya menghentikan program.
Lebih baik:
try:
do_work()
except ValueError as error:
handle_invalid_input(error)
Jika memang perlu menangkap banyak exception:
try:
do_work()
except (ValueError, KeyError) as error:
...
Jika menangkap Exception, pastikan ada alasan kuat dan biasanya log/re-raise.
try:
run_job()
except Exception:
logger.exception("Unexpected job failure")
raise
7. Jangan Menelan Error
Buruk:
def load_cases(path: Path) -> list[Case]:
try:
...
except Exception:
return []
Masalah:
- file corrupt dianggap empty;
- permission error dianggap empty;
- JSON invalid dianggap empty;
- bug mapping dianggap empty;
- data loss bisa terjadi saat save berikutnya.
Lebih baik bedakan:
def load_cases(path: Path) -> list[Case]:
if not path.exists():
return []
raw_content = path.read_text(encoding="utf-8")
if not raw_content.strip():
return []
return parse_cases(raw_content)
Jika JSON invalid, biarkan error naik atau translate dengan jelas.
8. Catch Specific Exception
Contoh storage:
import json
from pathlib import Path
class CaseStoreCorruptedError(Exception):
def __init__(self, path: Path) -> None:
super().__init__(f"Case store is corrupted: {path}")
self.path = path
def load_cases(path: Path) -> list[Case]:
if not path.exists():
return []
raw_content = path.read_text(encoding="utf-8")
try:
data = json.loads(raw_content)
except json.JSONDecodeError as error:
raise CaseStoreCorruptedError(path) from error
return [case_from_dict(item) for item in data]
Kita hanya menangkap JSONDecodeError, bukan semua exception.
Kenapa?
- missing file sudah ditangani;
- permission error biarkan naik;
- bug mapping biarkan terlihat;
- JSON corrupted diterjemahkan menjadi error storage yang lebih bermakna.
9. Exception Chaining
Gunakan raise ... from error saat menerjemahkan exception.
try:
data = json.loads(raw_content)
except json.JSONDecodeError as error:
raise CaseStoreCorruptedError(path) from error
Manfaat:
- high-level error jelas;
- root cause tetap terlihat;
- traceback menyimpan chain;
- debugging lebih mudah.
Tanpa chaining:
except json.JSONDecodeError:
raise CaseStoreCorruptedError(path)
Root cause bisa kurang jelas.
9.1 Suppressing Context
Kadang kamu ingin menyembunyikan internal detail:
raise UserFacingError("Invalid input") from None
Gunakan hati-hati. Untuk internal logs, root cause biasanya berguna. Untuk user-facing error, internal detail bisa disembunyikan di boundary, bukan dengan menghilangkan observability.
10. Re-Raising
Jika ingin menangkap untuk logging lalu meneruskan error, gunakan bare raise dalam except block.
try:
run_job()
except Exception:
logger.exception("Job failed")
raise
Jangan:
except Exception as error:
raise error
Karena ini bisa mengubah traceback dan membuat diagnosis kurang bersih.
11. try/except/else/finally
Python punya struktur lengkap:
try:
result = risky_operation()
except ExpectedError as error:
handle(error)
else:
use(result)
finally:
cleanup()
11.1 else
else berjalan jika tidak ada exception di try.
try:
data = json.loads(raw_content)
except json.JSONDecodeError as error:
raise CaseStoreCorruptedError(path) from error
else:
return [case_from_dict(item) for item in data]
else berguna untuk membatasi area try agar tidak menangkap exception yang tidak dimaksud.
11.2 finally
finally selalu berjalan, baik ada exception atau tidak.
resource = acquire_resource()
try:
use_resource(resource)
finally:
resource.close()
Untuk resource, context manager (with) sering lebih baik.
with path.open("w", encoding="utf-8") as file:
file.write(content)
12. Context Manager untuk Cleanup
File handling:
with open("cases.json", "r", encoding="utf-8") as file:
content = file.read()
Jika error terjadi saat read, file tetap ditutup.
Context manager dipakai untuk:
- file;
- lock;
- temporary directory;
- database transaction;
- network session;
- test patching;
- resource lifecycle.
Untuk storage sederhana, Path.read_text() dan Path.write_text() sudah mengelola file open/close secara internal.
13. Custom Exception
Custom exception berguna saat error punya makna domain/application.
Contoh:
class CaseTrackerError(Exception):
"""Base class for case-tracker errors."""
class CaseNotFoundError(CaseTrackerError):
def __init__(self, case_id: str) -> None:
super().__init__(f"Case not found: {case_id}")
self.case_id = case_id
class InvalidCaseTransitionError(CaseTrackerError):
def __init__(self, from_status: CaseStatus, to_status: CaseStatus) -> None:
super().__init__(f"Cannot transition case from {from_status.value} to {to_status.value}")
self.from_status = from_status
self.to_status = to_status
Base exception memudahkan boundary catch:
try:
...
except CaseTrackerError as error:
print(error)
return 1
Tetapi jangan terlalu cepat menangkap base exception jika butuh behavior berbeda per error.
14. Exception Taxonomy
Untuk aplikasi, buat taxonomy sederhana.
Contoh code:
class CaseTrackerError(Exception):
pass
class DomainError(CaseTrackerError):
pass
class ApplicationError(CaseTrackerError):
pass
class InfrastructureError(CaseTrackerError):
pass
Lalu:
class InvalidCaseTransitionError(DomainError):
...
class CaseNotFoundError(ApplicationError):
...
class CaseStoreCorruptedError(InfrastructureError):
...
Untuk mini project, taxonomy ini mungkin terasa formal. Tetapi untuk sistem besar, ia membantu:
- error mapping;
- logging;
- retry policy;
- user message;
- monitoring;
- test clarity.
15. Domain Error vs Infrastructure Error
15.1 Domain Error
Domain error terjadi karena rule bisnis/domain dilanggar.
Contoh:
- invalid transition;
- title kosong;
- note kosong;
- close case tanpa approval;
- escalation tanpa reason.
Domain error biasanya tidak retryable. Jika input sama, hasilnya tetap gagal.
15.2 Infrastructure Error
Infrastructure error terjadi karena dependency teknis gagal.
Contoh:
- file tidak bisa dibaca;
- database down;
- network timeout;
- permission denied;
- JSON store corrupt;
- disk full.
Infrastructure error kadang retryable, kadang tidak.
15.3 Kenapa Perlu Dibedakan?
Karena handling berbeda.
| Error | Retry? | User Message | Log Severity |
|---|---|---|---|
| Invalid transition | Tidak | Jelaskan rule | Warning/Info |
| Case not found | Tidak, kecuali race | Case tidak ditemukan | Info/Warning |
| Timeout DB | Mungkin | Coba lagi nanti | Error |
| Store corrupt | Tidak otomatis | Hubungi admin | Error/Critical |
| Permission denied | Tidak sampai config diperbaiki | System unavailable | Error |
16. Return None vs Raise
Tidak semua absence harus exception.
16.1 find_ Return None
def find_case(cases: list[Case], case_id: str) -> Case | None:
for case in cases:
if case.id == case_id:
return case
return None
Cocok jika missing adalah kemungkinan normal.
16.2 get_ Raise
def get_case(cases: list[Case], case_id: str) -> Case:
case = find_case(cases, case_id)
if case is None:
raise CaseNotFoundError(case_id)
return case
Cocok jika caller membutuhkan case dan tidak bisa lanjut tanpa itu.
Rule:
findboleh return optional;getbiasanya raise;- konsistensi lebih penting daripada nama spesifik.
17. Avoid Sentinel Ambiguity
Buruk:
def get_case(case_id: str) -> Case | None:
try:
return repository.get(case_id)
except Exception:
return None
None bisa berarti:
- case tidak ada;
- database down;
- permission denied;
- bug deserialization;
- timeout.
Ini merusak semantics.
Lebih baik:
def find_case(case_id: str) -> Case | None:
try:
return repository.find(case_id)
except RepositoryUnavailableError:
raise
Atau repository menyediakan error spesifik.
18. EAFP vs LBYL
Python sering memakai EAFP: “Easier to Ask Forgiveness than Permission”.
Contoh EAFP:
try:
value = data["status"]
except KeyError:
...
LBYL: “Look Before You Leap”.
if "status" in data:
value = data["status"]
else:
...
Keduanya valid.
Gunakan EAFP jika:
- operasi idiomatik;
- race condition mungkin terjadi antara check dan use;
- exception memang bagian dari API;
- code lebih sederhana.
Gunakan LBYL jika:
- check murah dan lebih jelas;
- missing field adalah validation concern;
- kamu ingin mengumpulkan banyak error;
- exception akan terlalu sering dalam hot path.
Dalam domain validation, LBYL sering lebih jelas:
if target_status not in ALLOWED_TRANSITIONS[current_status]:
raise InvalidCaseTransitionError(...)
19. Defensive Python
Defensive bukan berarti menangkap semua error. Defensive berarti failure mode dipikirkan.
Contoh defensive validation:
def normalize_title(title: str) -> str:
normalized = title.strip()
if not normalized:
raise ValueError("Case title cannot be empty")
return normalized
Contoh defensive boundary:
def case_from_dict(data: dict) -> Case:
try:
return Case(
id=data["id"],
title=data["title"],
status=CaseStatus(data["status"]),
notes=list(data.get("notes", [])),
)
except KeyError as error:
raise CaseStoreCorruptedError("Missing required case field") from error
Defensive design bertanya:
- input dari mana?
- bisa dipercaya?
- field wajib apa?
- field optional apa?
- error apa yang harus diterjemahkan?
- error apa yang harus naik?
- apakah caller bisa memperbaiki?
20. Validation Boundary
Data dari luar harus divalidasi di boundary.
Sumber luar:
- CLI args;
- environment variable;
- JSON file;
- HTTP request;
- database row;
- message queue;
- user input;
- third-party API.
Contoh CLI boundary:
def parse_case_status(raw_status: str) -> CaseStatus:
normalized = raw_status.strip().upper()
try:
return CaseStatus(normalized)
except ValueError as error:
allowed = ", ".join(status.value for status in CaseStatus)
raise ValueError(f"Invalid status: {raw_status}. Allowed statuses: {allowed}") from error
Contoh JSON boundary:
def case_from_dict(data: dict) -> Case:
...
Domain object sebaiknya tidak menerima data mentah tanpa validasi.
21. Error Message Design
Pesan error baik:
- spesifik;
- actionable;
- tidak membocorkan secret;
- mencantumkan value relevan;
- tidak terlalu verbose;
- cocok untuk audience.
Buruk:
Invalid
Lebih baik:
Invalid status: WAITING. Allowed statuses: DRAFT, SUBMITTED, UNDER_REVIEW, ESCALATED, CLOSED
Buruk:
Database password mysecret123 failed for user admin
Jangan log atau tampilkan secret.
21.1 Developer Message vs User Message
Exception message internal tidak selalu sama dengan user-facing message.
Internal:
Case store is corrupted: /var/app/cases.json
User:
Case data cannot be loaded. Please contact support.
CLI sederhana boleh menampilkan exception message, tetapi production API harus mapping lebih hati-hati.
22. Logging Exceptions
Jangan log exception di setiap layer. Itu menyebabkan duplicate logs.
Buruk:
try:
service()
except CaseTrackerError:
logger.exception("Service failed")
raise
Lalu boundary juga log.
Rule:
- log di boundary atau recovery point;
- lower layer raise dengan context;
- jangan log dan suppress tanpa alasan;
- gunakan
logger.exception()dalam except block untuk menyertakan traceback.
Contoh:
try:
run_job()
except Exception:
logger.exception("Unexpected job failure")
return 1
Untuk CLI belajar, print cukup. Nanti observability akan memakai logging.
23. Exception Safety dan Partial Side Effects
Pertanyaan penting:
Apa yang sudah berubah sebelum exception terjadi?
Contoh:
def transition_case(path: Path, case_id: str, target_status: CaseStatus) -> Case:
cases = load_cases(path)
case = get_case_from_list(cases, case_id)
case.transition_to(target_status)
save_cases(path, cases)
send_notification(case)
return case
Jika send_notification gagal:
- status sudah tersimpan;
- notification belum terkirim;
- caller menerima exception;
- apakah retry akan mengirim notification?
- apakah transition akan dijalankan ulang?
- apakah idempotent?
Untuk mini project, kita belum punya notification. Tetapi pola ini penting.
Rule awal:
- Validate sebelum side effect.
- Minimalkan side effect dalam satu function.
- Urutkan side effect dengan sadar.
- Dokumentasikan partial failure jika ada.
- Untuk operasi penting, pertimbangkan transaction/outbox pattern nanti.
24. Atomic File Write
Storage sederhana:
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
Jika process crash saat write, file bisa corrupt.
Lebih defensif:
def atomic_write_text(path: Path, content: str) -> None:
temp_path = path.with_suffix(path.suffix + ".tmp")
temp_path.write_text(content, encoding="utf-8")
temp_path.replace(path)
Gunakan di save_cases:
def save_cases(path: Path, cases: list[Case]) -> None:
data = [case_to_dict(case) for case in cases]
content = json.dumps(data, indent=2)
atomic_write_text(path, content)
Ini bukan sempurna untuk semua filesystem/OS scenario, tetapi lebih baik daripada direct overwrite.
Konsep penting:
Failure semantics juga berlaku pada persistence.
25. Designing Case Tracker Errors
Refactor domain.py:
class CaseTrackerError(Exception):
pass
class DomainError(CaseTrackerError):
pass
class InvalidCaseTransitionError(DomainError):
def __init__(self, from_status: CaseStatus, to_status: CaseStatus) -> None:
super().__init__(f"Cannot transition case from {from_status.value} to {to_status.value}")
self.from_status = from_status
self.to_status = to_status
service.py:
class ApplicationError(CaseTrackerError):
pass
class CaseNotFoundError(ApplicationError):
def __init__(self, case_id: str) -> None:
super().__init__(f"Case not found: {case_id}")
self.case_id = case_id
storage.py:
class InfrastructureError(CaseTrackerError):
pass
class CaseStoreCorruptedError(InfrastructureError):
def __init__(self, path: Path) -> None:
super().__init__(f"Case store is corrupted: {path}")
self.path = path
Namun hati-hati: jika base class ada di domain.py, maka storage import domain sudah OK. Tetapi jika InfrastructureError di storage dan domain butuh base, bisa muncul dependency question. Untuk project kecil, bisa buat module:
errors.py
Berisi base exception umum.
26. Should Errors Live in errors.py?
Pilihan:
26.1 Error di Module Terkait
domain.py -> InvalidCaseTransitionError
service.py -> CaseNotFoundError
storage.py -> CaseStoreCorruptedError
Kelebihan:
- dekat dengan sumber error;
- sederhana;
- tidak ada module extra.
Kekurangan:
- boundary yang ingin catch semua error harus import dari beberapa module;
- base taxonomy tersebar.
26.2 Error di errors.py
errors.py -> CaseTrackerError, DomainError, ApplicationError, InfrastructureError
domain.py -> import DomainError
Kelebihan:
- taxonomy jelas;
- boundary bisa catch base error;
- mengurangi duplication.
Kekurangan:
- module baru;
- bisa menjadi dumping ground jika tidak disiplin.
Untuk mini project, error di module terkait cukup. Saat error taxonomy tumbuh, pindahkan base classes ke errors.py.
27. CLI Error Boundary
CLI boundary harus catch error yang bisa dipresentasikan.
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return handle_command(args)
except CaseTrackerError as error:
print(error)
return 1
except ValueError as error:
print(f"Invalid input: {error}")
return 1
Namun jangan catch unexpected bug terlalu agresif.
Untuk development, biarkan unexpected exception muncul dengan traceback.
Untuk production CLI, bisa:
except Exception:
logger.exception("Unexpected error")
print("Unexpected error. See logs for details.")
return 1
Tetapi di fase belajar, traceback membantu self-correction. Jangan sembunyikan terlalu awal.
28. HTTP/API Error Boundary Preview
Nanti saat memakai FastAPI, mapping bisa seperti:
| Exception | HTTP Status |
|---|---|
CaseNotFoundError | 404 |
InvalidCaseTransitionError | 409 atau 400 |
ValueError input | 400 |
CaseStoreUnavailableError | 503 |
| Unexpected exception | 500 |
Domain tidak boleh raise HTTPException langsung.
Buruk:
def transition_to(...):
raise HTTPException(status_code=400)
Lebih baik:
raise InvalidCaseTransitionError(...)
API boundary yang menerjemahkan.
29. Testing Exceptions
Gunakan pytest:
import pytest
def test_invalid_transition_raises_error():
case = Case(id="CASE-001", title="Late reporting")
with pytest.raises(InvalidCaseTransitionError):
case.transition_to(CaseStatus.CLOSED)
Cek message:
with pytest.raises(ValueError, match="Title cannot be empty"):
create_case(" ")
Cek attribute:
with pytest.raises(InvalidCaseTransitionError) as exc_info:
case.transition_to(CaseStatus.CLOSED)
assert exc_info.value.from_status is CaseStatus.DRAFT
assert exc_info.value.to_status is CaseStatus.CLOSED
Test exception bukan hanya memastikan error terjadi, tetapi memastikan error membawa informasi yang benar.
30. Testing Exception Chaining
import json
import pytest
def test_load_cases_wraps_json_decode_error(tmp_path):
path = tmp_path / "cases.json"
path.write_text("{invalid json", encoding="utf-8")
with pytest.raises(CaseStoreCorruptedError) as exc_info:
load_cases(path)
assert isinstance(exc_info.value.__cause__, json.JSONDecodeError)
__cause__ berasal dari raise ... from error.
Ini memastikan root cause tidak hilang.
31. Error Handling Smells
31.1 Bare Except
except:
...
31.2 Catch and Ignore
except Exception:
pass
31.3 Catch Too Broad Too Early
def domain_function():
try:
...
except Exception:
return None
31.4 Lost Traceback
except SomeError as error:
raise OtherError(str(error))
Lebih baik:
raise OtherError(...) from error
31.5 Print in Lower Layer
def load_cases(...):
print("Could not load")
Storage harus raise, boundary present.
31.6 Error Message Too Vague
raise ValueError("Bad")
31.7 Error Message Leaks Secret
raise ValueError(f"Invalid token: {token}")
31.8 Using Exception for Normal Loop Control Excessively
Exception boleh untuk EAFP, tetapi jangan membuat flow normal sulit dibaca.
31.9 Returning Magic Values
return -1
Tanpa contract jelas.
31.10 Converting Every Error to RuntimeError
Menghilangkan semantics.
32. Failure Mode Table
Untuk case-tracker:
| Failure | Layer Deteksi | Exception | Boundary Handling |
|---|---|---|---|
| Empty title | Domain/factory | ValueError atau InvalidCaseTitleError | Print invalid input |
| Invalid status string | CLI parsing | ValueError | Print allowed statuses |
| Invalid transition | Domain | InvalidCaseTransitionError | Print transition error |
| Missing case id | Service | CaseNotFoundError | Print not found |
| Missing file | Storage | no error, return empty | Normal |
| Empty file | Storage | no error, return empty | Normal |
| Invalid JSON | Storage | CaseStoreCorruptedError | Print data corrupted |
| Permission denied | OS/storage | PermissionError | Unexpected or mapped infra error |
| Duplicate case id | Service/storage validation | DuplicateCaseIdError | Print data invariant error |
This table is design documentation.
33. Practice: Refactor Storage Error
Current:
def load_cases(path: Path) -> list[Case]:
if not path.exists():
return []
raw_content = path.read_text(encoding="utf-8")
if not raw_content.strip():
return []
data = json.loads(raw_content)
return [case_from_dict(item) for item in data]
Refactor:
class CaseStoreCorruptedError(Exception):
def __init__(self, path: Path) -> None:
super().__init__(f"Case store is corrupted: {path}")
self.path = path
def load_cases(path: Path) -> list[Case]:
if not path.exists():
return []
raw_content = path.read_text(encoding="utf-8")
if not raw_content.strip():
return []
try:
data = json.loads(raw_content)
except json.JSONDecodeError as error:
raise CaseStoreCorruptedError(path) from error
return [case_from_dict(item) for item in data]
Tambahkan test invalid JSON.
34. Practice: Split Find and Get
Implement:
def find_case(cases: list[Case], case_id: str) -> Case | None:
for case in cases:
if case.id == case_id:
return case
return None
def get_case_from_list(cases: list[Case], case_id: str) -> Case:
case = find_case(cases, case_id)
if case is None:
raise CaseNotFoundError(case_id)
return case
Test:
def test_find_case_returns_none_when_missing():
assert find_case([], "CASE-001") is None
def test_get_case_from_list_raises_when_missing():
with pytest.raises(CaseNotFoundError):
get_case_from_list([], "CASE-001")
Manfaat:
- semantics jelas;
- caller memilih behavior;
transition_casebisa pakaiget.
35. Practice: Exception Attributes
Update:
class InvalidCaseTransitionError(Exception):
def __init__(self, from_status: CaseStatus, to_status: CaseStatus) -> None:
self.from_status = from_status
self.to_status = to_status
super().__init__(f"Cannot transition case from {from_status.value} to {to_status.value}")
Test:
def test_invalid_transition_error_contains_statuses():
case = Case(id="CASE-001", title="Late reporting")
with pytest.raises(InvalidCaseTransitionError) as exc_info:
case.transition_to(CaseStatus.CLOSED)
assert exc_info.value.from_status is CaseStatus.DRAFT
assert exc_info.value.to_status is CaseStatus.CLOSED
Error object harus membawa data yang berguna, bukan hanya string.
36. Practice: Improve CLI Status Error
Implement:
def parse_case_status(raw_status: str) -> CaseStatus:
normalized = raw_status.strip().upper()
try:
return CaseStatus(normalized)
except ValueError as error:
allowed = ", ".join(status.value for status in CaseStatus)
raise ValueError(f"Invalid status: {raw_status}. Allowed statuses: {allowed}") from error
Test:
def test_parse_case_status_error_message_lists_allowed_values():
with pytest.raises(ValueError, match="Allowed statuses"):
parse_case_status("waiting")
37. Practice: Failure Mode Review
Ambil function:
def transition_case(path: Path, case_id: str, target_status: CaseStatus) -> Case:
cases = load_cases(path)
case = get_case_from_list(cases, case_id)
case.transition_to(target_status)
save_cases(path, cases)
return case
Jawab:
- Apa saja exception yang mungkin muncul?
- Dari layer mana exception itu berasal?
- Mana domain error?
- Mana application error?
- Mana infrastructure error?
- Mana yang boleh ditampilkan ke user?
- Mana yang perlu log?
- Apa partial side effect yang mungkin terjadi?
- Apakah function ini idempotent?
- Apa yang terjadi jika
save_casesgagal?
38. Practice: Avoid Swallowing Error
Kode buruk:
def safe_load_cases(path: Path) -> list[Case]:
try:
return load_cases(path)
except Exception:
return []
Refactor dengan semantics:
def load_cases_or_empty_if_missing(path: Path) -> list[Case]:
if not path.exists():
return []
return load_cases(path)
Atau jika CLI ingin present:
try:
cases = load_cases(path)
except CaseStoreCorruptedError as error:
print(error)
return 1
Jangan mengubah semua failure menjadi empty list.
39. Self-Check
Jawab tanpa melihat materi:
- Apa itu failure semantics?
- Kapan raise exception?
- Kapan return
None? - Apa beda
finddangetsemantics? - Kenapa bare except buruk?
- Kenapa catch
Exceptionterlalu awal buruk? - Apa itu exception chaining?
- Kapan memakai
raise ... from error? - Apa beda domain error dan infrastructure error?
- Kenapa domain tidak boleh print error?
- Kenapa CLI boleh catch dan print?
- Apa guna custom exception attribute?
- Apa fungsi
finally? - Kapan context manager lebih baik dari
finallymanual? - Apa itu partial side effect?
- Kenapa validate before side effect?
- Apa risiko return empty list saat JSON corrupt?
- Bagaimana test exception dengan pytest?
- Apa itu
logger.exception()? - Apa error handling smell paling berbahaya?
40. Definition of Done Part 010
Kamu selesai part ini jika bisa:
- Menjelaskan exception hierarchy dasar.
- Menulis custom exception.
- Menulis
try/exceptspesifik. - Menghindari bare except.
- Menjelaskan exception chaining.
- Menulis
raise ... from error. - Menjaga traceback saat re-raise.
- Membedakan domain/application/infrastructure error.
- Membuat failure mode table.
- Refactor storage invalid JSON menjadi custom error.
- Split
find_casedanget_case_from_list. - Test exception dengan pytest.
- Test exception attributes.
- Menjelaskan error boundary CLI.
- Menjelaskan partial side effect dalam
transition_case.
41. Ringkasan
Exception adalah bagian dari desain, bukan sekadar mekanisme crash.
Inti part ini:
- failure semantics harus jelas;
- raise exception saat function tidak bisa memenuhi contract;
- return
Nonehanya jika absence adalah hasil normal dan jelas; - catch exception di layer yang bisa mengambil keputusan;
- hindari bare except dan swallowing error;
- gunakan custom exception untuk domain/application semantics;
- gunakan exception chaining agar root cause tidak hilang;
- bedakan domain error dan infrastructure error;
- validation boundary penting untuk data eksternal;
- error message harus spesifik dan aman;
- lower layer raise, boundary layer present;
- partial side effect harus dipikirkan;
- tests harus mencakup failure path.
Part berikutnya akan membahas iteration, comprehension, generator, dan lazy thinking: bagaimana Python memproses aliran data dan bagaimana menulis transformasi yang efisien serta readable.
42. Referensi
- Python Documentation — Errors and Exceptions.
- Python Documentation — Built-in Exceptions.
- Python Documentation — Exception Chaining.
- Python Documentation —
contextlib. - pytest Documentation —
pytest.raises.
You just completed lesson 10 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.