Python · 异常体系与 traceback

BaseException 家族树 + try/except/else/finally 执行时序 — 异常链与 Exception Group, 把 traceback 变成排障资产

BaseException 家族树 BaseException except BaseException 才能拦住一切 KeyboardInterrupt Ctrl+C SystemExit sys.exit() GeneratorExit gen.close() Exception 业务异常总代理 ← except Exception 拦不住的三个漏网之鱼 ArithmeticError ZeroDivisionError... LookupError KeyError / IndexError ValueError 非法值 / 解析失败 TypeError 类型不对 OSError FileNotFoundError... RuntimeError RecursionError... 祖辈 except 自动覆盖后代: except OSError 连 FileNotFoundError 一起接住 — 子类分支必须写在前 无异常 抛异常 干净完成 命中, 处理完 未匹配 → 仍过 finally return in finally try / except / else / finally 时序 try: 冒险区 只放可能抛异常的最小代码段 else: 无异常才走 依赖 try 结果的代码放这 else 里抛的错不被本 except 接 except: 从上往下 第一个匹配的接走, 即刻止 子类必须写在父类前面 finally: 无论如何必执行 (return 前最后一站) 只做清理 — 绝不 return / raise / break 正常续行 else + finally 完成的路径 沿调用栈上抛 未匹配异常离开本帧 ⚠ finally 里 return / raise = 异常黑洞 try 抛的异常、except/else 的返回值, 全被覆盖丢弃 调用方只拿到 finally 给的值 — 最难查的一类静默 bug Exception Group (3.11+) ExceptionGroup( 'batch failed', [TimeoutError(), ValueError()]) except* TimeoutError as eg eg = 匹配的子组, 仍是一组 except* ValueError as eg 各分支互不影响 都不匹配 → 整组上抛 未处理的子组不能被吞 asyncio.TaskGroup 的失败 抛的就是 ExceptionGroup 异常链: raise ... from e __cause__ 保留根因栈 不写 from 也有 __context__ from None = 主动掐断 (慎用) Legend 概念 / 阶段 正常路径 异常路径 / 危险 异常类型 / 分组 finally / 清理 / 异常链

匹配顺序即语义

  • • except 从上往下, 第一个 isinstance 命中即止
  • • 子类分支必须写在父类前面, 否则被截胡
  • • 裸 except 捕 BaseException, Ctrl+C 都杀不死
  • • else 只在 try 干净时走 — 缩小 except 误伤面

异常是 API 的一部分

  • • 项目根异常 + 分支子类, 带 code / http_status
  • • raise X() from e 保留根因链, 排障不丢栈
  • • SDK 异常进翻译层, 业务层只认领域异常
  • • 并发批量失败用 ExceptionGroup 聚合上抛

traceback 是排障资产

  • • logger.exception / exc_info=True 全栈必留
  • • request_id 关联一次请求的所有日志行
  • • Sentry 按异常类型+栈指纹聚合成告警
  • • 字符串匹配异常消息做分支 = 下次发版就碎

💡 一句话理解

异常是 Python 的控制流原语: try 划出冒险区, except 按类型从上往下找第一个能接的, 没抛就走 else, 最后无论哪条路都要过一遍 finally。匹配是 isinstance 语义 — 祖辈 except 能接住所有后代, 所以子类必须写在前面; 而 BaseException 下有三个连 except Exception 都拦不住的漏网之鱼 (KeyboardInterrupt/SystemExit/GeneratorExit), 这正是"永远别写裸 except"的原因。

把异常当资产经营: 自定义层级带 code/http_status 让错误可被程序消费, raise ... from 保留因果链, logger.exception 把完整 traceback 送进日志, 最外层中间件统一翻译成 HTTP 响应 — 这一套做齐, 生产事故的定位时间从小时级降到分钟级。

🧠 必知必会 必考 & 必会

匹配顺序
except 从上往下, 第一个 isinstance 命中即止。子类写在前, 否则被祖辈截胡, 后面的分支变成永远不会执行的死代码。
try:
    raise FileNotFoundError("x")
except OSError:            # 关键: 祖辈先写 = 截胡
    print("os")           # → os (子类是 OSError)
except FileNotFoundError:
    print("fnf")          # 死代码, 永远走不到
裸 except vs Exception
裸 except 等价 except BaseException, 连 Ctrl+C/SystemExit/GeneratorExit 都吞 — 服务杀不死、生成器关不掉。底线写法是 except Exception。
issubclass(KeyboardInterrupt, Exception)      # → False!
issubclass(KeyboardInterrupt, BaseException)  # → True
# 关键: except Exception 拦不住 Ctrl+C/SystemExit
# 所以裸 except(= BaseException)连服务退出信号都吞
raise ... from e
设置 __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()    # 关键: 整条链的字符串
finally 覆盖语义
finally 里的 return/raise/break/continue 会覆盖 try/except 里的一切: 异常被吞、返回值被换。finally 只做清理, 永不返回。
def f():
    try:
        return 1          # 关键: 被 finally 覆盖
    finally:
        return 2
f()                           # → 2, try 的 1 丢了
else 的价值
try 没抛才执行。把"依赖结果的后半段"挪进 else, 让 except 只包真正可能抛的行 — 误伤面最小, 读者一眼看清异常边界。
try:
    v = cache[key]            # 只包真正可能抛的行
except KeyError:
    v = load(key)
else:
    validate(v)               # 关键: try 没抛才走
# else 里抛的错不会被上面的 except 误接
exc_info / 栈对象
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") 自动附当前栈
ExceptionGroup / except*
3.11+ 把一组异常打包上抛; except* T as eg 拿到的是"匹配的子组"(仍是组), 各分支独立, 未匹配的子组继续传播 — 不能静默吞掉。
raise ExceptionGroup("batch", [TimeoutError(), ValueError()])
try: ...
except* TimeoutError as eg:    # 关键: eg 是匹配子组
    len(eg.exceptions)         # → 1, 仍是"组"
except* ValueError as eg: ...  # 各分支互不影响
TaskGroup
asyncio.TaskGroup 等全部子任务结束后, 把所有失败聚成一个 ExceptionGroup 抛出, 一个不漏 — 并发采集类任务的标配容器。
async with asyncio.TaskGroup() as tg:
    for u in urls:
        tg.create_task(fetch(u))
# 关键: 全部结束后, 所有失败聚成一个 ExceptionGroup
# 一个不漏, 不用再 gather(return_exceptions=True) 自己分拣
自定义异常基类
项目根异常 (如 AppError) 挂 code/http_status/retryable 字段, API 层统一翻译成响应; 新增错误只继承一行, 不再手写映射 if-else。
class AppError(Exception):
    code = "internal"; http_status = 500
class NotFound(AppError):
    code, http_status = "not_found", 404  # 关键: 新错误只继承一行
# API 层: except AppError 统一翻译成 HTTP 响应
EAFP vs LBYL
"先斩后奏" (try) 与"三思后行" (if 预检)。dict/锁/权限场景 EAFP 更快也更原子 — 检查与使用之间没有竞态窗口; 3.11+ 无异常路径 try 零开销。
# LBYL: 检查与使用之间有竞态窗口
if key in d: use(d[key])
# EAFP: 原子, 3.11+ 无异常路径零开销
try:
    use(d[key])
except KeyError: ...           # 关键: 更快也更原子
裸 raise vs raise e
except 块里裸 raise 原样重抛, 栈起点保持在原始抛出处; raise e 会把栈起点挪到当前行, 原始位置信息变弱。续抛一律裸 raise。
try:
    risky()
except ValueError:
    log.warning("ctx")
    raise                    # 关键: 栈起点保持在原始抛出处
# raise e → 栈起点挪到当前行, 位置信息变弱
控制流的边界
缺数据/提前退出/重试用异常表达没问题; 但异常路径比正常返回慢 1-2 个数量级, 热循环里高频"预期内异常" (疯狂 KeyError) 是性能 bug, 该换 get/default。
for k in keys:
    total += d[k] + 1     # 错: 高频 KeyError = 性能 bug
total += d.get(k, 0) + 1  # 对: get/default
# 关键: "预期内"的缺省别走异常路径, 异常留给真异常

🏭 生产实战 real world

场景 1 · AppError(code, http_status) + API 层统一异常映射

错误码散落在几十个 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)

场景 2 · 千万行 ETL: EAFP 风格提升脏数据吞吐

逐行预检 (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 预检 — 异常只留给真正"异常"的路径。

场景 3 · TaskGroup 并发采集: 一组异常聚合全部失败原因

并发抓 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))

场景 4 · 结构化错误日志: exc_info + Sentry 上报

支付失败只记一句 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                                   # 原样续抛, 栈不动

场景 5 · raise ... from e: 解析层包装不丢根因

把 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

场景 6 · 事务/锁的 finally 清理模板

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/异常三条路都关连接

场景 7 · 批量同步的"部分失败"策略

一万条记录同步, 不能一条坏废一批, 也不能静默跳过。收集失败项继续跑, 结尾统一报告:

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])
    # 失败率超阈值再中断整批 — 策略显式写在代码里

场景 8 · tenacity 白名单重试: 只重可重试异常

裸 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/验证失败/解析错误绝不重试 — 那不是"暂时性"故障

场景 9 · 第三方 SDK 异常翻译层

业务代码里到处 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  # 未知: 包一层带上 下文

场景 10 · 生产兜底中间件: unhandled → 500 + request_id 关联

总有漏网的异常。最外层中间件统一接住: 全栈进日志、响应只回安全信息、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

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 裸 except: 吞掉 Ctrl+C — kill -INT 服务不退, 滚动更新卡到超时, 只能 kill -9 硬撕丢数据。原因: 裸 except 捕的是 BaseException, KeyboardInterrupt/SystemExit 全中招。正解: except Exception: 起步; 退出信号交给 signal handler 处理。
while True:
    try:
        job()
    except:               # 错: Ctrl+C 也吞, 杀不死
        pass
    except Exception:       # 对: 底线写法
        log.exception("job failed")
坑 2 · except: pass 静默吞错 — 上游错误被咽掉, 数据悄悄少一截, 三天后对账才发现缺口。正解: 至少 log.warning(..., exc_info=True); 真"可忽略"的写注释说明为何可忽略, 让 review 的人敢放过。
# 错: 静默吞错, 数据悄悄少一截
try: sync(rec)
except Exception: pass
# 对: 留栈 + 注释说明为何可忽略
try: sync(rec)
except Exception: log.warning("skip", exc_info=True)
坑 3 · 捕获过宽把 bug 洗成 500 — 顶层 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")
坑 4 · raise ... from None 掐断根因 — 包装时随手 from None, 日志只剩外层一句话, 原始解析栈整段消失。正解: 保留 from e; 只有确认原异常是纯噪音 (已知 NoSuchKey) 才 from None。
raise ConfigError("bad") from None  # 错: 原始解析栈整段消失
raise ConfigError("bad") from e    # 对: __cause__ 保根因
# 仅确认纯噪音 (已知 NoSuchKey) 才用 from None
坑 5 · finally 里 return 吞异常 — try 抛出的异常在 finally 的 return False 处被无声丢弃, 调用方以为成功继续跑。正解: finally 只做清理; "失败返回默认值"的语义写在 except 里并记日志。
def f():
    try:
        risky()            # 抛 ValueError
    finally:
        return False     # 错: 异常无声丢弃
f()                         # → False, 调用方继续跑
# 对: 默认值语义写进 except 并记日志, finally 只清理
坑 6 · except 里 return 伪装成正常值 — 读配置失败返回空串, 上游把"配置缺失"当"空配置"处理, 故障延迟爆发。正解: 可恢复才兜底且必须 log+指标; 不可恢复就 raise, 别让错误路径产出与正常路径同型的值。
try:
    token = cfg["token"]
except KeyError:
    return ""            # 错: 缺失被当"空配置", 延迟爆发
# 对: 不可恢复就 raise; 可恢复必须 log+指标
raise ConfigMissing("token")
坑 7 · 循环内 try 粒度错误 — try 包住整个 for: 一条脏数据废掉一整批; 或写了单条 try 但 continue 放错位置, 脏数据照样往下走。正解: 粒度收到"单条处理"级, 跳过/中断/收集三选一并把策略写进日志。
# 错: 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)
坑 8 · 字符串匹配异常消息做分支 — if "refused" in str(e), SDK 升级改了文案立刻全线失效。正解: 按异常类型分 (子类粒度), 用库自带的 code/errno 属性, 或在边界包一层自己的翻译异常。
if "refused" in str(e):    # 错: SDK 改文案立刻碎
    retry()
# 对: 按类型与 errno 分支
except ConnectionRefusedError: retry()
# 或: e.errno == errno.ECONNREFUSED
坑 9 · 自定义异常只剩一句 msg — 排障时无法按 code 聚合, 监控里全是 "error occurred"。正解: 异常类带 code/retryable/上下文字段; __repr__ 打印全量字段, 日志直接可读。
raise ValueError("error occurred")   # 错: 无法按 code 聚合
# 对: 带 code/retryable 字段
class SyncError(AppError):
    code, retryable = "sync_failed", True
坑 10 · except as e 的自动 del — 块结束自动 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
坑 11 · assert 当业务校验 — python -O 优化模式下 assert 全部被剥掉, 生产校验"消失", 脏数据直捅 DB。正解: 业务校验显式 raise 领域异常; assert 只表达"不可能发生"的内部不变量。
assert qty > 0, "qty must be positive"  # 错: -O 下被剥掉
# 对: 业务校验显式 raise
if qty <= 0:
    raise InvalidQty(qty)
# assert 只留给"不可能发生"的内部不变量
坑 12 · 回调里引用循环变量 e — 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 当参数显式传入亦可
坑 13 · 重试不区分异常类型放大雪崩 — 裸 retry 见异常就重: 401/参数错也重五次, 下游被打爆。正解: retry_if_exception_type 白名单只重暂时性故障 (超时/连接), 配合退避与断路器。
@retry                        # 错: 见异常就重, 401 也重五次
def fetch(url): ...
# 对: 白名单只重暂时性故障 + 指数退避
@retry(retry=retry_if_exception_type((TimeoutError, ConnectionError)))
def fetch(url): ...
坑 14 · logger.error(str(e)) 丢栈 — 日志只剩一行消息, 是哪条调用链炸的完全没线索。正解: logger.exception("msg") 或 error(..., exc_info=True), 全栈是排障的底线资产。
except PaymentError as e:
    log.error(str(e))              # 错: 只剩一行, 调用链没线索
# 对: 全栈是底线资产
except PaymentError:
    log.exception("payment failed")   # 自动附当前异常全栈
坑 15 · f-string 提前格式化异常消息 — 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
坑 16 · 嵌套 try 读错栈 — 外层接到的其实是 except 块里二次抛的新异常, 真凶藏在 __context__ 的 "During handling..." 链里, 排查看错对象。正解: except 里做可能失败的动作要单独 try; 读栈先看链尾再往上追。
try:
    risky()
except ValueError:
    cleanup()                # 错: 二次抛错, 真凶藏进 __context__
# 对: except 里的可能失败动作单独包
try:
    cleanup()
except Exception:
    log.warning("cleanup failed", exc_info=True)
坑 17 · 捕 BaseException 收编系统退出 — 当"万能兜底"把 SystemExit/GeneratorExit 也转成 500 或重试, 进程杀不掉、生成器关不干净。正解: 永远 except Exception; GeneratorExit 更不能拦, 让 finally 完成清理。
except BaseException:          # 错: 收编 SystemExit, 杀不掉
    return JSONResponse({}, status_code=500)
# 对: 永远 except Exception
except Exception:
    log.exception("unhandled")
坑 18 · finally 里再抛新异常 — cleanup() 在 finally 里抛错, 覆盖 try 的原始异常, 真凶消失只留清理失败。正解: finally 里清理动作自带 try/except 记日志; 明确可忽略的用 contextlib.suppress。
finally:
    cleanup()                # 错: 抛错覆盖原始异常, 真凶消失
# 对: 清理动作自带保护
finally:
    try: cleanup()
    except Exception: log.warning("cleanup failed")
# 明确可忽略: contextlib.suppress(OSError)
坑 19 · raise e 改写栈起点 — 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 保住因果
坑 20 · StopIteration 泄漏 — 生成器里手动 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