BaseException 家族树 + try/except/else/finally 执行时序 — 异常链与 Exception Group, 把 traceback 变成排障资产
异常是 Python 的控制流原语: try 划出冒险区, except 按类型从上往下找第一个能接的, 没抛就走 else, 最后无论哪条路都要过一遍 finally。匹配是 isinstance 语义 — 祖辈 except 能接住所有后代, 所以子类必须写在前面; 而 BaseException 下有三个连 except Exception 都拦不住的漏网之鱼 (KeyboardInterrupt/SystemExit/GeneratorExit), 这正是"永远别写裸 except"的原因。
把异常当资产经营: 自定义层级带 code/http_status 让错误可被程序消费, raise ... from 保留因果链, logger.exception 把完整 traceback 送进日志, 最外层中间件统一翻译成 HTTP 响应 — 这一套做齐, 生产事故的定位时间从小时级降到分钟级。
try: raise FileNotFoundError("x") except OSError: # 关键: 祖辈先写 = 截胡 print("os") # → os (子类是 OSError) except FileNotFoundError: print("fnf") # 死代码, 永远走不到
except BaseException, 连 Ctrl+C/SystemExit/GeneratorExit 都吞 — 服务杀不死、生成器关不掉。底线写法是 except Exception。 issubclass(KeyboardInterrupt, Exception) # → False! issubclass(KeyboardInterrupt, BaseException) # → True # 关键: except Exception 拦不住 Ctrl+C/SystemExit # 所以裸 except(= BaseException)连服务退出信号都吞
__cause__ 显式因果链; 不写 from 时, 处理过程中抛出的新异常也会记进 __context__ 隐式链; from None 主动掐断, 只在确认原异常是纯噪音时用。 try: json.loads(raw) except ValueError as e: raise ConfigError("bad") from e # 关键: __cause__ 保留根因栈 # 不写 from → __context__ 隐式链; from None → 主动掐断
__cause__ (显式因果) / __context__ (隐式因果) / __traceback__ (栈对象); traceback.format_exc() 打的是整条链, 排障按链读。 import traceback try: 1 / 0 except ZeroDivisionError as e: print(e.__cause__) # → None (无显式 from) tb = e.__traceback__ # 栈对象, 可编程读帧 traceback.format_exc() # 关键: 整条链的字符串
def f(): try: return 1 # 关键: 被 finally 覆盖 finally: return 2 f() # → 2, try 的 1 丢了
try: v = cache[key] # 只包真正可能抛的行 except KeyError: v = load(key) else: validate(v) # 关键: try 没抛才走 # else 里抛的错不会被上面的 except 误接
sys.exc_info() 或 e.__traceback__ 拿栈; logger.exception("msg") 自动附当前异常全栈, 等价于 error(..., exc_info=True)。 try: charge(order) except PaymentError: log.error("pay failed", exc_info=True) # 关键: 全栈必留 # 等价写法: log.exception("pay failed") 自动附当前栈
except* T as eg 拿到的是"匹配的子组"(仍是组), 各分支独立, 未匹配的子组继续传播 — 不能静默吞掉。 raise ExceptionGroup("batch", [TimeoutError(), ValueError()]) try: ... except* TimeoutError as eg: # 关键: eg 是匹配子组 len(eg.exceptions) # → 1, 仍是"组" except* ValueError as eg: ... # 各分支互不影响
asyncio.TaskGroup 等全部子任务结束后, 把所有失败聚成一个 ExceptionGroup 抛出, 一个不漏 — 并发采集类任务的标配容器。 async with asyncio.TaskGroup() as tg: for u in urls: tg.create_task(fetch(u)) # 关键: 全部结束后, 所有失败聚成一个 ExceptionGroup # 一个不漏, 不用再 gather(return_exceptions=True) 自己分拣
class AppError(Exception): code = "internal"; http_status = 500 class NotFound(AppError): code, http_status = "not_found", 404 # 关键: 新错误只继承一行 # API 层: except AppError 统一翻译成 HTTP 响应
# LBYL: 检查与使用之间有竞态窗口 if key in d: use(d[key]) # EAFP: 原子, 3.11+ 无异常路径零开销 try: use(d[key]) except KeyError: ... # 关键: 更快也更原子
raise 原样重抛, 栈起点保持在原始抛出处; raise e 会把栈起点挪到当前行, 原始位置信息变弱。续抛一律裸 raise。 try: risky() except ValueError: log.warning("ctx") raise # 关键: 栈起点保持在原始抛出处 # raise e → 栈起点挪到当前行, 位置信息变弱
for k in keys: total += d[k] + 1 # 错: 高频 KeyError = 性能 bug total += d.get(k, 0) + 1 # 对: get/default # 关键: "预期内"的缺省别走异常路径, 异常留给真异常
错误码散落在几十个 handler 里各写各的, 排障与前端对接都是灾难。定义异常层级, 在最外层一次性翻译成 HTTP:
class AppError(Exception): code = "internal"; http_status = 500 def __init__(self, msg, **ctx): super().__init__(msg) self.ctx = ctx # ctx 进日志不进响应 — 防泄漏 class NotFound(AppError): code, http_status = "not_found", 404 class Conflict(AppError): code, http_status = "conflict", 409 class RateLimited(AppError): code, http_status = "rate_limited", 429 @app.exception_handler(AppError) # 全项目唯一的出口 async def on_app_error(req, exc): log.warning("app_error", code=exc.code, ctx=exc.ctx, exc_info=True) return JSONResponse({"code": exc.code}, status_code=exc.http_status)
逐行预检 (get+None 判断) 每行多两次字典查找; 3.11+ 无异常时 try 零开销, 脏行占比低时先斩后奏更快:
total = 0 skipped = 0 for row in rows: # 千万行, 99.9% 是正常整数 try: total += int(row["qty"]) # 直接上手, 不预检 except (KeyError, ValueError): # 只精确捕这两类脏数据 skipped += 1 continue # 单行粒度隔离, 一条脏不废一批 metrics.gauge("etl.skipped", skipped)
对比预检版: 全量耗时 -18%; 若脏行占比超过几成, 反转用 get 预检 — 异常只留给真正"异常"的路径。
并发抓 200 个源, 旧写法 gather(return_exceptions=True) 拿一坨列表自己分拣; TaskGroup + except* 按类型各取所需:
import asyncio async def fetch_all(urls): async with asyncio.TaskGroup() as tg: # 等全部结束, 失败不丢 tasks = [tg.create_task(fetch(u)) for u in urls] return [t.result() for t in tasks] try: asyncio.run(fetch_all(urls)) except* TimeoutError as eg: # 只拿超时子组, 互不影响 log.warning(f"{len(eg.exceptions)} sources timed out") except* aiohttp.ClientError as eg: metrics.count("fetch.client_error", len(eg.exceptions))
支付失败只记一句 str(e) 等于没记。字段进 extra 供 ELK 检索, 栈走 exc_info, Sentry 按指纹聚合同类错误:
try: charge(order) except PaymentError as e: log.error("payment_failed", extra={"order_id": order.id, "psp": e.psp, # 结构化字段可检索 "retryable": e.retryable}, exc_info=True) # 全栈必须留 — str(e) 什么都没剩下 capture_exception(e) # Sentry: 同类错误聚成一个 issue raise # 原样续抛, 栈不动
把 JSONDecodeError 包成业务异常时最常见的失误是丢链 — 排障时只剩最后一句话, 原始位置没了:
def load_config(raw: bytes): try: return json.loads(raw) except ValueError as e: # JSONDecodeError 是其子类 raise ConfigError("config invalid") from e # __cause__ 保留解析栈 # 日志里看到完整因果: # ConfigError: config invalid # The above exception was the direct cause of... # json.decoder.JSONDecodeError: Expecting ',' delimiter: line 3 column 40
commit/rollback/close 三件事各有归属: 异常时回滚、无论如何关连接, 用裸 raise 保证栈不被改写:
def run_in_tx(fn): tx = db.begin() try: result = fn(tx) tx.commit() return result # 正常返回 (也要等 finally 跑完) except Exception: tx.rollback() raise # 裸 raise: 栈起点不动, 继续上抛 finally: tx.close() # commit/rollback/异常三条路都关连接
一万条记录同步, 不能一条坏废一批, 也不能静默跳过。收集失败项继续跑, 结尾统一报告:
failed = [] for rec in records: try: sync(rec) # 粒度 = 单条, 隔离失败半径 except ValidationError as e: failed.append({"id": rec.id, "err": str(e)[:200]}) metrics.incr("sync.rejected") # 实时指标, 不等结尾 if failed: notify_ops(f"sync: {len(failed)}/{len(records)} failed", failed[:50]) # 失败率超阈值再中断整批 — 策略显式写在代码里
裸 retry 见异常就重, 401/参数错误也重五次, 雪崩放大配额打爆。重试必须限定异常类型:
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential @retry(retry=retry_if_exception_type((ConnectionError, TimeoutError)), # 白名单 stop=stop_after_attempt(4), wait=wait_exponential(multiplier=0.3), # 0.3/0.6/1.2s 退避 reraise=True) # 用尽后抛原始异常, 不包 RetryError def fetch(url): ... # 4xx/验证失败/解析错误绝不重试 — 那不是"暂时性"故障
业务代码里到处 import boto3 的异常类型, 换存储引擎就要改全站。在边界处一次性翻译成领域异常:
class Storage: # SDK 异常不许穿透到业务层 def get(self, key): try: return self.s3.get_object(Bucket=self.b, Key=key)["Body"].read() except self.s3.exceptions.NoSuchKey: raise NotFound(key) from None # 已知缺键: 领域异常, 掐断噪音链 except EndpointConnectionError as e: raise StorageUnavailable(str(e)) from e # 未知: 包一层带上 下文
总有漏网的异常。最外层中间件统一接住: 全栈进日志、响应只回安全信息、request_id 把一次请求的所有日志串起来:
@app.middleware("http") async def catch_all(req, call_next): rid = req.headers.get("x-request-id", new_rid()) # 贯穿全链路的关联键 try: resp = await call_next(req) except Exception: # 兜底 — 但必须留全栈+告警 log.exception("unhandled", request_id=rid) # exc_info: 现场一个不丢 alert.fire("unhandled_500", rid=rid) return JSONResponse({"code": "internal", "request_id": rid}, status_code=500) # 不回栈给客户端, 防泄漏 resp.headers["x-request-id"] = rid return resp
BaseException, KeyboardInterrupt/SystemExit 全中招。正解: except Exception: 起步; 退出信号交给 signal handler 处理。 while True: try: job() except: # 错: Ctrl+C 也吞, 杀不死 pass except Exception: # 对: 底线写法 log.exception("job failed")
log.warning(..., exc_info=True); 真"可忽略"的写注释说明为何可忽略, 让 review 的人敢放过。 # 错: 静默吞错, 数据悄悄少一截 try: sync(rec) except Exception: pass # 对: 留栈 + 注释说明为何可忽略 try: sync(rec) except Exception: log.warning("skip", exc_info=True)
except Exception 把 typo (NameError/AttributeError) 也转成 500, 真正的编程错误被兜底掩盖。正解: 兜底必须 log.exception 留全栈+告警; 业务层只精确捕获领域异常, 让 bug 炸得明显。 # 错: typo 也被洗成 500, 编程错误被掩盖 except Exception as e: return JSONResponse({"code": "internal"}, status_code=500) # 对: 兜底必须 log.exception 留全栈 + 告警 log.exception("unhandled"); alert.fire("unhandled_500")
from None, 日志只剩外层一句话, 原始解析栈整段消失。正解: 保留 from e; 只有确认原异常是纯噪音 (已知 NoSuchKey) 才 from None。 raise ConfigError("bad") from None # 错: 原始解析栈整段消失 raise ConfigError("bad") from e # 对: __cause__ 保根因 # 仅确认纯噪音 (已知 NoSuchKey) 才用 from None
return False 处被无声丢弃, 调用方以为成功继续跑。正解: finally 只做清理; "失败返回默认值"的语义写在 except 里并记日志。 def f(): try: risky() # 抛 ValueError finally: return False # 错: 异常无声丢弃 f() # → False, 调用方继续跑 # 对: 默认值语义写进 except 并记日志, finally 只清理
try: token = cfg["token"] except KeyError: return "" # 错: 缺失被当"空配置", 延迟爆发 # 对: 不可恢复就 raise; 可恢复必须 log+指标 raise ConfigMissing("token")
# 错: try 包整个 for, 一条脏废一批 try: for rec in recs: sync(rec) except ValidationError: ... # 对: 粒度收到单条 for rec in recs: try: sync(rec) except ValidationError as e: failed.append(rec.id)
if "refused" in str(e), SDK 升级改了文案立刻全线失效。正解: 按异常类型分 (子类粒度), 用库自带的 code/errno 属性, 或在边界包一层自己的翻译异常。 if "refused" in str(e): # 错: SDK 改文案立刻碎 retry() # 对: 按类型与 errno 分支 except ConnectionRefusedError: retry() # 或: e.errno == errno.ECONNREFUSED
__repr__ 打印全量字段, 日志直接可读。 raise ValueError("error occurred") # 错: 无法按 code 聚合 # 对: 带 code/retryable 字段 class SyncError(AppError): code, retryable = "sync_failed", True
del e (防引用环), 在块外或闭包里再引用 e 报 NameError。正解: 块内先存引用 err = e, 之后统一用 err。 try: risky() except ValueError as e: pass print(e) # 错: → NameError, e 已被 del except ValueError as e: err = e # 对: 块内先存引用, 之后用 err
python -O 优化模式下 assert 全部被剥掉, 生产校验"消失", 脏数据直捅 DB。正解: 业务校验显式 raise 领域异常; assert 只表达"不可能发生"的内部不变量。 assert qty > 0, "qty must be positive" # 错: -O 下被剥掉 # 对: 业务校验显式 raise if qty <= 0: raise InvalidQty(qty) # assert 只留给"不可能发生"的内部不变量
except Exception as e: cbs.append(lambda: handle(e)), 回调执行时 e 已是最后一轮的异常, 报错误位。正解: 默认参数绑定 lambda e=e: handle(e), 或把 e 当参数显式传入。 except Exception as e: cbs.append(lambda: handle(e)) # 错: 执行时 e 是末轮的 # 对: 默认参数绑定当下值 except Exception as e: cbs.append(lambda e=e: handle(e)) # e 当参数显式传入亦可
retry_if_exception_type 白名单只重暂时性故障 (超时/连接), 配合退避与断路器。 @retry # 错: 见异常就重, 401 也重五次 def fetch(url): ... # 对: 白名单只重暂时性故障 + 指数退避 @retry(retry=retry_if_exception_type((TimeoutError, ConnectionError))) def fetch(url): ...
logger.exception("msg") 或 error(..., exc_info=True), 全栈是排障的底线资产。 except PaymentError as e: log.error(str(e)) # 错: 只剩一行, 调用链没线索 # 对: 全栈是底线资产 except PaymentError: log.exception("payment failed") # 自动附当前异常全栈
log.debug(f"ctx {e}") 每次都求值 (级别关了也付钱), 还把敏感字段烧死在消息串里。正解: log.debug("ctx %s", e) 惰性格式化; 敏感字段走 extra 结构化。 log.debug(f"ctx {e}") # 错: 级别关了也求值, 敏感字段烧死 # 对: 惰性格式化 + 结构化字段 log.debug("ctx %s", e) # 只有真要输出才格式化 log.error("ctx", extra={"err": e}) # 敏感走 extra
try: risky() except ValueError: cleanup() # 错: 二次抛错, 真凶藏进 __context__ # 对: except 里的可能失败动作单独包 try: cleanup() except Exception: log.warning("cleanup failed", exc_info=True)
except Exception; GeneratorExit 更不能拦, 让 finally 完成清理。 except BaseException: # 错: 收编 SystemExit, 杀不掉 return JSONResponse({}, status_code=500) # 对: 永远 except Exception except Exception: log.exception("unhandled")
contextlib.suppress。 finally: cleanup() # 错: 抛错覆盖原始异常, 真凶消失 # 对: 清理动作自带保护 finally: try: cleanup() except Exception: log.warning("cleanup failed") # 明确可忽略: contextlib.suppress(OSError)
except X as e: ...; raise e 把 traceback 起点挪到当前行, 原始抛出位置被弱化。正解: 原样续抛用裸 raise; 跨层翻译用 raise New() from e 保住因果。 except ValueError as e: raise e # 错: 栈起点挪到当前行 except ValueError: raise # 对: 原样续抛, 栈起点不动 # 跨层翻译: raise NewError(...) from e 保住因果
next(it) 不给默认值, StopIteration 冒出去把外层 for 提前"正常"截断 (PEP 479 后生成器内转 RuntimeError)。正解: next(it, None) 显式默认; 迭代边界自己管。 def gen(it): while True: v = next(it) # 错: 迭代完 StopIteration 冒出去 yield transform(v) v = next(it, None) # 对: 显式默认, 边界自己管 if v is None: return