ข้ามไปยังเนื้อหา

Custom Errors

built-in exception ของ Python (ValueError, TypeError, RuntimeError, …) เป็นแบบทั่วไป เมื่อโค้ดของคุณ raise ValueError caller ไม่มีทางรู้ว่ามันมาจาก input ของตัวเอง, config file ที่พัง หรือ network response

Custom exception class ให้ error ของคุณมี type ที่ชัดเจนที่ caller เลือกจับได้, เพิ่ม attribute ที่มีโครงสร้าง (เช่น HTTP status code หรือ field name) และ document ว่าแต่ละส่วนของ application ผิดพลาดได้อย่างไรบ้าง

custom exception ที่น้อยที่สุดคือ class ที่ inherit จาก Exception:

class AppError(Exception):
"""Base class for all application errors."""

แค่นี้ก็ raise และจับมันโดยระบุชื่อได้แล้ว:

raise AppError("something went wrong")

override __init__ เพื่อแนบ context เพิ่มเข้าไปใน exception:

class AppError(Exception):
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message) # pass message to Exception
self.code = code

caller สามารถอ่าน err.code เพื่อได้ข้อมูลที่มีโครงสร้างโดยไม่ต้อง parse message string

สร้าง subclass สำหรับแต่ละหมวดของความล้มเหลว NotFoundError ทุกตัวก็เป็น AppError ด้วย ทำให้ caller จับได้ในระดับความเจาะจงที่ต้องการ

class ValidationError(AppError):
def __init__(self, field: str, message: str) -> None:
super().__init__(f'Validation failed on "{field}": {message}', code=400)
self.field = field
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id: int) -> None:
super().__init__(f"{resource} id={resource_id} not found", code=404)
self.resource = resource
self.resource_id = resource_id
flowchart TD
EXC["Exception"] --> APP["AppError (code: int)"]
APP --> VAL["ValidationError (field: str)"]
APP --> NF["NotFoundError (resource: str, resource_id: int)"]
Application error hierarchy ขนาดเล็ก: leaf ทุกตัวก็เป็น AppError ด้วย

จับที่ระดับต่างกัน:

try:
raise ValidationError("email", "must contain @")
except ValidationError as e:
print(f"Field {e.field!r} invalid: {e}") # specific
except AppError as e:
print(f"App error [{e.code}]: {e}") # broad

package ที่มีหลาย module มักนิยาม root exception หนึ่งตัวต่อ package (หรือหนึ่งตัวต่อ subsystem) แล้วให้ exception อื่นทุกตัวใน package นั้น inherit จากมัน — caller ที่สนใจแค่ “package นี้ล้มเหลวหรือเปล่า” จับที่ root ได้ ส่วน caller ที่ต้องการรายละเอียดต่อความล้มเหลวจับ subclass ที่เจาะจง

CPython รัน del e แบบ implicit เมื่อ except ... as e: block จบ — ไม่ว่าจะจบแบบปกติหรือผ่าน exception อื่น นี่ไม่ใช่ทางเลือกเรื่อง style แต่เป็นพฤติกรรมของภาษา และมันหมายความว่าชื่อนั้นหายไปทันทีหลังจากนั้น:

try:
raise ValueError("boom")
except ValueError as e:
print(e) # fine -- e is bound here
print(e) # NameError: name 'e' is not defined

สิ่งนี้มีไว้เพื่อตัด reference cycle: traceback ของ exception object ถือ reference ไปยัง frame และ local ของ frame นั้นก็จะถือ reference กลับไปที่ exception ทำให้ทั้งคู่มีชีวิตอยู่ตลอดไปถ้าไม่มีการลบอัตโนมัตินี้ ผลในทาง ปฏิบัติ: อย่าวางแผนใช้ชื่อที่ผูกไว้หลัง except block เลย — คัดลอกสิ่งที่ต้องใช้ (message, attribute) ไปเป็นชื่อ อื่นก่อน อย่างที่ demo ด้านล่างทำโดยใช้ sample แทนการใช้ e ซ้ำ

error ที่เห็นจริงขึ้นอยู่กับ scope: ที่ระดับ module การอ่านชื่อที่ถูกลบไปแล้ว raise NameError: name 'e' is not defined อย่างที่เห็นข้างบน ส่วนภายใน function e เป็นตัวแปร local และ compiler ของ Python รู้ตั้งแต่ตอน compile time แล้วว่าชื่อนี้เป็น local ของ function นั้น การอ่านมันหลัง implicit del จะ raise UnboundLocalError: cannot access local variable 'e' where it is not associated with a value แทน ถึงแม้โค้ด จะ “ดูเหมือนกัน” กับกรณีระดับ module ก็ตาม ทั้งสองคือการลบแบบเดียวกัน ต่างกันแค่ class ของ error ตาม scope

BaseException ทุกตัวเก็บ positional argument ที่ส่งเข้ามาไว้ใน .args — super().__init__(message) คือสิ่งที่ เติม self.args ด้วย (message,)

str(e) คืน message ที่ส่งเข้าไปใน super().__init__() นี่คือสิ่งที่พิมพ์ออกมาเมื่อ print(e) หรือใส่ exception ใน f-string

repr(e) คืน ExceptionClass(message) — มีประโยชน์สำหรับ logging เพราะมันบอกด้วยว่า exception type ไหนสร้าง message นี้

err = AppError("disk full", code=507)
str(err) # 'disk full'
repr(err) # "AppError('disk full')"
err.args # ('disk full',)
err.code # 507 -- custom attributes are NOT part of .args

attribute เองอย่าง code อยู่บน instance ไม่ใช่ใน .args — มันถูก set โดย __init__ ของคุณเอง ไม่ใช่โดย Exception.__init__

การ raise exception ใหม่ใน except block จะบันทึก exception ที่กำลังถูก handle อยู่เสมอ แม้ไม่มี from — Python set __context__ ให้อัตโนมัติ raise ... from original set __cause__ เพิ่มเข้ามาด้วย และ traceback printer default ของ Python เปลี่ยนคำพูดระหว่างสองแบบนี้ (“during handling of the above exception” สำหรับ context แบบ implicit เทียบกับ “the direct cause of the following exception” สำหรับ cause แบบ explicit)

try:
int("nope")
except ValueError:
raise RuntimeError("second failure") # no 'from' -- __context__ set implicitly, __cause__ is None
try:
int("nope")
except ValueError as original:
raise RuntimeError("second failure") from original # __cause__ set explicitly

raise ... from None เป็นวิธีเดียวที่ suppress ทั้งคู่ ได้ — มันบอก Python ว่า exception ใหม่นี้ไม่เกี่ยวข้อง กับสิ่งที่กำลังถูก handle อยู่จริง ๆ

BaseException.add_note(note) เพิ่ม string เข้าไปใน __notes__ list ของ exception ซึ่ง traceback printer default จะรวมมันให้อัตโนมัติ มีประโยชน์สำหรับแนบ context ที่รู้ หลังจาก exception object ถูกสร้างไปแล้ว — เช่น ระหว่างที่มัน propagate ผ่าน retry loop:

err = AppError("upstream timeout", code=504)
err.add_note("retry attempt 1 of 3")
err.add_note("upstream host: api.internal:8443")
raise err

ต่างจากการสร้าง message string ที่ยาวขึ้นตั้งแต่แรก note ถูกเพิ่มแบบ incremental และปรากฏเป็นบรรทัดแยกใน traceback ทำให้ message เดิมยังคงอยู่ครบสำหรับอะไรก็ตามที่ parse หรือเทียบมัน

เมื่อไหร่ควร subclass builtin เทียบกับนิยาม base ของตัวเอง

หัวข้อที่มีชื่อว่า “เมื่อไหร่ควร subclass builtin เทียบกับนิยาม base ของตัวเอง”

การ subclass builtin (class NegativeAmountError(ValueError):) หมายความว่า caller เดิมทุกตัวที่จับ ValueError อยู่แล้วยังทำงานต่อได้โดยไม่เปลี่ยนแปลง ในขณะที่ caller ที่ต้องการ attribute เพิ่มของคุณจับ subclass ที่เจาะจงได้ การนิยาม base ของตัวเอง (class AppError(Exception):) เหมาะเมื่อความล้มเหลวนั้นเฉพาะกับ domain ของ application คุณจริง ๆ และไม่มี builtin ตัวไหนที่สื่อความหมายที่ถูกต้องอยู่แล้ว — บังคับให้ caller ทุกตัว ทั้งเก่า และใหม่ ผ่าน hierarchy ของคุณโดยตั้งใจ

class NegativeAmountError(ValueError):
"""A ValueError with extra context -- callers who only know ValueError still catch it."""
def __init__(self, amount: float) -> None:
super().__init__(f"amount must be non-negative, got {amount}")
self.amount = amount

except ValueError: แบบทั่วไปยังจับ NegativeAmountError ได้เหมือนเดิม ส่วนโค้ดที่ต้องการ .amount โดยเฉพาะจับ NegativeAmountError ตรง ๆ เลือก subclass builtin ทุกครั้งที่ error ของคุณ เป็น กรณีเจาะจงกว่าของ error ที่มีอยู่แล้วจริง ๆ ใช้ base เฉพาะของ application ใหม่เฉพาะเมื่อไม่ใช่กรณีนั้น

# --- base application error ---
class AppError(Exception):
"""Base class for all application errors."""
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message)
self.code = code
# --- specific subclasses ---
class ValidationError(AppError):
"""Raised when input validation fails."""
def __init__(self, field: str, message: str) -> None:
super().__init__(
f'Validation failed on field "{field}": {message}',
code=400,
)
self.field = field
class NotFoundError(AppError):
"""Raised when a requested resource does not exist."""
def __init__(self, resource: str, resource_id: int) -> None:
super().__init__(
f"{resource} with id={resource_id} not found",
code=404,
)
self.resource = resource
self.resource_id = resource_id
# --- catching at different levels ---
errors = [
ValidationError("email", "must contain @"),
NotFoundError("User", 42),
AppError("generic app failure", code=500),
]
for err in errors:
try:
raise err
except ValidationError as e:
print(f"[{e.code}] Validation on {e.field!r}: {e}")
except NotFoundError as e:
print(f"[{e.code}] {e.resource} not found (id={e.resource_id})")
except AppError as e:
print(f"[{e.code}] AppError: {e}")
# --- isinstance hierarchy ---
# 'e' is deleted when the except block ends (CPython) — use a new name here
print()
sample = ValidationError("name", "too short")
print(f"ValidationError? {isinstance(sample, ValidationError)}")
print(f"AppError? {isinstance(sample, AppError)}")
print(f"Exception? {isinstance(sample, Exception)}")
print(f"BaseException? {isinstance(sample, BaseException)}")
# --- except X as e: deletes e when the block ends ---
print("=== except ... as e deletes e ===")
try:
raise ValueError("boom")
except ValueError as e:
print(f" inside the except block: {e}")
print(" now outside the except block:")
try:
print(e) # NameError: 'e' was deleted by an implicit del e when the block ended
except NameError as ne:
print(f" NameError: {ne}")
# --- args and repr ---
print("=== args and repr ===")
class AppError(Exception):
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message)
self.code = code
err = AppError("disk full", code=507)
print(f" str(err) = {str(err)!r}")
print(f" repr(err) = {repr(err)!r}")
print(f" err.args = {err.args}")
print(f" err.code = {err.code}")
# --- implicit chaining: __context__ vs __cause__ ---
print("=== implicit chaining (__context__) vs explicit (__cause__) ===")
try:
try:
int("nope")
except ValueError:
raise RuntimeError("second failure") # no 'from' -- __context__ set implicitly
except RuntimeError as e:
print(f" no 'from': __cause__={e.__cause__!r} __context__={e.__context__!r}")
try:
try:
int("nope")
except ValueError as original:
raise RuntimeError("second failure") from original # __cause__ set explicitly
except RuntimeError as e:
print(f" with 'from': __cause__={e.__cause__!r}")
# --- add_note() (3.11+) ---
print("=== add_note() ===")
try:
err2 = AppError("upstream timeout", code=504)
err2.add_note("retry attempt 1 of 3")
err2.add_note("upstream host: api.internal:8443")
raise err2
except AppError as e:
print(f" {e}")
print(f" notes: {e.__notes__}")
# --- when to subclass a builtin vs your own base ---
print("=== builtin subclass vs custom base ===")
class NegativeAmountError(ValueError):
"""A ValueError with extra context -- callers who only know ValueError still catch it."""
def __init__(self, amount: float) -> None:
super().__init__(f"amount must be non-negative, got {amount}")
self.amount = amount
def withdraw(amount: float) -> float:
if amount < 0:
raise NegativeAmountError(amount)
return amount
try:
withdraw(-10.0)
except ValueError as e: # generic callers that only know about ValueError still catch this
print(f" caught as plain ValueError: {e}")
try:
withdraw(-10.0)
except NegativeAmountError as e: # callers who need the extra .amount attribute catch the specific type
print(f" caught as NegativeAmountError, amount={e.amount}")

Exception class คือ class ธรรมดาของ Python ดังนั้นทุกอย่างที่รู้เกี่ยวกับ class ใช้ได้หมด: __init__ ตั้งค่า attribute, isinstance เดินตาม MRO และการ inherit หมายถึง “จับได้ในนาม” สิ่งเดียวที่พิเศษเฉพาะของ except ... as e: คือ del e อัตโนมัติของ CPython ตอนจบ block — statement แบบ implicit ตัวเดียวนี้คือต้นเหตุ ทั้งพฤติกรรมตัด reference cycle และความประหลาดใจแบบ NameError ข้างบน

สิ่งที่ได้ประโยชน์ต้นทุน
Custom exception hierarchycaller จับได้ที่ระดับความเจาะจงที่ถูกต้องพอดีต้องนิยาม class เพิ่มและดูแลจัดระเบียบ
Subclass builtin (ValueError)catcher ทั่วไปที่มีอยู่แล้วยังทำงานต่อได้ไม่ explicit เท่า application base เฉพาะ
Custom attribute (.code, .field)ข้อมูลมีโครงสร้างโดยไม่ต้อง parse messageboilerplate __init__ เพิ่มต่อ exception class
add_note()แนบ context ที่รู้หลังสร้างได้ โดยไม่ต้องสร้าง message ใหม่note เป็นแค่ text ต่อท้าย ไม่ใช่ structured data
  • “ใช้ e ต่อหลัง except block ได้เหมือนตัวแปรอื่น” — CPython ลบมันอัตโนมัติเมื่อ block จบ แม้จะจบแบบสำเร็จ ก็ตาม — ใช้มันทีหลัง raise NameError
  • “attribute เองอย่าง .code ถูกเก็บใน .args” — ไม่ใช่ — .args เก็บแค่สิ่งที่ส่งให้ Exception.__init__ (หรือ BaseException.__init__) attribute เองเป็น instance attribute แยกที่คุณ set เอง
  • “raise exception ใหม่โดยไม่มี from จะทิ้งตัวเดิมไป” — ไม่จริง — Python ยังบันทึกมันไว้ใน __context__ อัตโนมัติ มีแค่ raise ... from None เท่านั้นที่ทิ้งมันจริง ๆ
  • “custom error ทุกตัวต้องมี base class ใหม่ของตัวเอง” — ถ้าความล้มเหลวเป็น ValueError (หรือ KeyError, TypeError, …) ที่เจาะจงกว่าจริง ๆ ให้ subclass builtin — handler ทั่วไปที่มีอยู่แล้วยังทำงานต่อได้ฟรี ๆ

ก่อนเชื่อ custom-exception hierarchy ที่ AI generate ให้ตรวจสิ่งเหล่านี้:

  • มีโค้ดที่อ่าน binding ของ except ... as e: หลัง block จบไปแล้วหรือเปล่า? มันจะ raise NameError ทันทีที่ path นั้นถูกรันจริง
  • attribute เองอย่าง .code หรือ .field ถูก set เป็น instance attribute จริงหรือเปล่า หรือโค้ดพยายามดึงมันจาก .args?
  • hierarchy subclass BaseException ตรง ๆ แทนที่จะเป็น Exception หรือเปล่า? นั่นเสี่ยงต่อการจับ KeyboardInterrupt หรือ SystemExit โดยไม่ตั้งใจทุกที่ที่ base ใหม่นี้ถูกจับแบบกว้าง ๆ
  • มี exception class ใหม่ถูกนิยามสำหรับสิ่งที่จริง ๆ แล้วเป็นแค่ ValueError หรือ KeyError แฝงตัวอยู่หรือเปล่า? เช็คว่าการ subclass builtin จะทำให้ caller เดิมยังทำงานต่อได้หรือไม่
  • การ re-raise ใน except block ต้องการ from None หรือเปล่า แต่โค้ดที่ generate ปล่อยให้ chain __context__ แบบ implicit อยู่ พิมพ์ traceback ซ้อนที่สับสนให้ end user เห็น?

🐛 หา bug ในโค้ดนี้

def parse_amount(raw: str) -> float:
try:
return float(raw)
except ValueError as e:
log_message = f"failed to parse {raw!r}: {e}"
print(log_message)
print(e) # meant to log the original exception object too
return 0.0
parse_amount("not-a-number")
เฉลย

e ถูกผูกไว้แค่ระหว่างที่ except ValueError as e: block กำลังทำงานเท่านั้น CPython รัน del e แบบ implicit ทันทีที่ block นั้นจบ ไม่ว่าจะจบแบบปกติหรือไม่ก็ตาม เพราะ e เป็นตัวแปร local ภายใน parse_amount การ print(e) สองบรรทัดถัดมาจึง raise UnboundLocalError: cannot access local variable 'e' where it is not associated with a value — ไม่ใช่ NameError (ซึ่งจะเกิดเฉพาะกับ e ระดับ module เท่านั้น) — ถึงแม้ log_message (ที่สร้างไว้ข้างใน block) จะยังอ่านได้ปกติดี ทางแก้คือ capture สิ่งที่ต้องใช้ทีหลัง — str(e), e.args หรือตัว exception object เอง — ไปเป็นชื่ออื่นก่อนที่ block จะจบ

ทำไม custom application exception ควร subclass Exception แทน BaseException?
จาก hierarchy NotFoundError -> AppError -> Exception except clause ไหนจะจับ NotFoundError ได้?
การเรียก super().__init__(message) ภายใน __init__ ของ custom exception ทำอะไร?
มี except ValidationError อยู่ก่อน except AppError ใน try block แล้วมี ValidationError ถูก raise clause ไหนทำงาน?
บรรทัดสุดท้ายจะเกิดอะไรขึ้น?
try:
    raise ValueError("boom")
except ValueError as e:
    msg = str(e)
print(e)
__context__ ของ RuntimeError ที่ raise ออกมาคืออะไร เมื่อไม่มี clause from?
try:
    int("x")
except ValueError:
    raise RuntimeError("second")
Application ของคุณ raise NegativeAmountError ที่นิยามเป็น class NegativeAmountError(ValueError): ... ประโยชน์หลักของตัวเลือกนี้เทียบกับ class NegativeAmountError(AppError): ... (base ใหม่ของ application) คืออะไร?