1. 为什么 Python 的异常处理不是“加个 try 就完事”——一个老手踩了三年坑才理清的逻辑
刚学 Python 的时候,我跟大多数人一样,把try...except当成万能创可贴:函数可能出错?包一层!读文件怕不存在?包一层!调 API 怕超时?再包一层!结果写出来的代码像俄罗斯套娃,嵌了四层try,每个except里只写一行print("出错了"),日志里全是“出错了”,但根本不知道错在哪、谁触发的、要不要重试、能不能忽略。直到我在一个金融数据清洗项目里,因为没区分FileNotFoundError和PermissionError,导致程序静默跳过权限不足的目录,把本该报警的生产事故当成了普通日志,被拉着开了整整两天复盘会。那一刻我才明白:Python 的异常处理机制,本质不是容错工具,而是程序控制流的精密调度系统——它决定什么时候中断、什么时候恢复、什么时候转换、什么时候兜底、什么时候彻底放弃。try是入口闸门,except是分流通道,finally是收尾工位,而raise和自定义异常,才是你真正握在手里的调度指令。这篇文章不讲语法定义,不列所有内置异常类,而是用我过去十年在数据管道、Web 后端、自动化脚本里反复打磨出的真实场景,拆解每一个关键字背后的决策逻辑:为什么有时候except Exception:是毒药,而有时候又非它不可;为什么finally里不能随便return;为什么else子句的存在感这么低,却在关键路径上救过我三次命;以及,如何用最少的代码行数,构建出能扛住网络抖动、磁盘满、配置错、用户乱输的健壮流程。如果你正在写一个要跑三个月不重启的爬虫,或者一个要处理十万条订单的批处理脚本,或者一个被前端疯狂点击的 API 接口,那么这篇内容不是“可学可不学”的知识点,而是你代码能否从“能跑”进化到“敢上线”的分水岭。
2. 异常处理的整体设计与思路拆解:从“防崩溃”到“控流程”
2.1 不是所有错误都叫“异常”,先分清三类故障源
很多新手一上来就猛写try,却忽略了 Python 运行时错误其实有清晰的谱系。我把它按“是否可预测、是否可恢复、是否应由当前层处理”分成三类,这直接决定了你该用if预检、用try捕获,还是该让上游来扛:
第一类:可预判的输入/状态错误(推荐用 if 预检)
比如用户传了个空字符串给json.loads(),或者传了个负数给开方函数。这类错误发生前,你完全能通过if not data:或if x < 0:提前拦截。我坚持的原则是:只要能用布尔判断解决的,绝不扔进try。原因有三:一是try块有隐式性能开销(Python 解释器要维护异常栈帧),二是过度依赖try会让代码逻辑变得“反直觉”——正常流程被异常分支打断;三是它掩盖了设计缺陷,比如本该在 API 入口做参数校验,却拖到业务逻辑里用try补救。在我维护的一个风控规则引擎里,我们强制要求所有float()转换前必须if value.strip() and value.replace('.', '').isdigit():,否则直接ValueError抛给网关层统一返回 400,而不是在计算模块里try: float(x) except: pass。第二类:运行时环境不确定性(必须用 try 捕获)
这才是try...except的主战场:文件突然被其他进程删除、数据库连接池耗尽、网络请求 DNS 解析失败、磁盘空间写满。这些错误无法在代码执行前预知,且往往需要差异化响应。比如对OSError,我要区分是errno.ENOSPC(磁盘满,需清理并告警)还是errno.EACCES(权限错,需运维介入);对requests.exceptions.Timeout,我要重试三次,而对requests.exceptions.ConnectionError,可能直接降级为缓存数据。这类错误的特点是:它们代表外部世界的状态变化,你的程序必须学会与之共舞,而不是假装它不存在。第三类:逻辑断言与契约破坏(用 assert 或自定义异常)
比如一个函数文档明确写着“输入必须为正整数”,结果传进来一个None。这时候if not isinstance(x, int) or x <= 0: raise ValueError("x must be positive integer")比try: ... except: ...更精准。assert适合开发调试阶段快速暴露假设失效,而自定义异常则用于向调用方声明契约——它告诉上游:“如果传错参数,我会抛这个特定异常,你爱怎么处理是你的事”。我在写一个机器学习特征工程库时,专门定义了FeatureNotAvailableError和DataDriftDetectedWarning(继承自Warning),让下游模型训练脚本能根据异常类型自动切换特征源或触发重训练。
提示:一个简单但极有效的自查法——当你写
except:时,立刻问自己:“这个错误发生时,我的程序知道该怎么恢复吗?如果不知道,那它就不该在这里被捕获,而应该向上冒泡。”
2.2 为什么except Exception:是把双刃剑?看懂它的捕获范围和陷阱
几乎所有教程都会警告“不要裸写except:”,但很少说清楚为什么。让我们用真实代码验证它的行为边界:
# 场景:一个读取配置的函数 def load_config(path): try: with open(path) as f: return json.load(f) except Exception as e: print(f"加载配置失败:{type(e).__name__}: {e}") return {"default": True}这段代码看似稳妥,实则埋了三个雷:
它捕获了
SystemExit和KeyboardInterrupt
如果用户在load_config()执行中按Ctrl+C,本该立即退出的程序,会被这个except Exception:拦下来,继续执行return {"default": True},导致程序无法被中断。Exception类虽然名字叫“异常”,但它并不包含BaseException的全部子类——SystemExit、KeyboardInterrupt、GeneratorExit这三个是直接继承BaseException的“顶层异常”,设计初衷就是让程序能干净退出。所以,任何可能被用户中断或需要优雅终止的长期运行服务,绝不能用except Exception:。它模糊了错误语义,让日志失去诊断价值
上面的print输出TypeError: expected str, bytes or os.PathLike object, not None,但调用方完全不知道这是path参数为None导致的,还是open()内部出了内存错误。更糟的是,如果json.load(f)抛出JSONDecodeError,它也会被归为Exception,而你丢失了JSONDecodeError自带的lineno和colno定位信息。我在线上环境见过最典型的案例:一个except Exception:日志里只写“处理失败”,结果排查三天才发现是某次部署漏传了证书文件,ssl.SSLCertVerificationError被吞掉了。它阻碍了异常链的传递与重构
Python 3 的异常链(raise ... from ...)是强大的调试工具。但如果你在中间层用except Exception:吞掉原始异常,再raise new_error,就切断了__cause__链。比如:try: risky_operation() except ValueError as e: # 正确:保留原始异常上下文 raise DataProcessingError("解析失败") from e # 错误:原始异常信息丢失 # except Exception as e: # raise DataProcessingError("解析失败")后者在 traceback 里只看到
DataProcessingError,前者能看到完整的“ValueError->DataProcessingError”链条,这对定位跨模块问题至关重要。
那么什么时候可以用except Exception:?我的经验是:仅限于最外层的“兜底捕获器”(如 Web 框架的全局异常处理器、CLI 主函数的最后防线),且必须做两件事:记录完整 traceback,然后重新抛出或转为用户友好的错误码。例如 FastAPI 的@app.exception_handler(Exception)中,我会logger.exception("Uncaught exception:")然后return JSONResponse(..., status_code=500),而不是静默吃掉。
2.3finally不是“善后代码区”,而是资源生命周期的守门人
很多人把finally理解为“无论成功失败都要执行的代码”,这没错,但太浅。它的核心价值在于保证资源释放的确定性,与业务逻辑的成败解耦。让我用一个数据库连接的真实案例说明:
# 危险写法:依赖 except 来关闭连接 def bad_query(sql): conn = get_db_connection() try: return conn.execute(sql).fetchall() except DatabaseError as e: conn.close() # 只在出错时关 raise # 如果成功,conn 永远不会被 close!内存泄漏! # 正确写法:用 finally 保证释放 def good_query(sql): conn = get_db_connection() try: return conn.execute(sql).fetchall() finally: conn.close() # 成功、失败、甚至 return 都会执行但finally的威力不止于此。它甚至能在try块中return之后执行:
def demo_finally_return(): try: return "from try" finally: print("finally runs even after return!") # 注意:这里不能 return,否则会覆盖 try 中的返回值 return "from function" # 这行永远不会执行 print(demo_finally_return()) # 输出: # finally runs even after return! # from try这个特性让我在写一个文件锁管理器时受益匪浅。锁的获取和释放必须严格配对,哪怕业务逻辑里return了,锁也得释放:
class FileLock: def __enter__(self): self.lock_file = open(self.path + ".lock", "w") fcntl.flock(self.lock_file, fcntl.LOCK_EX) return self def __exit__(self, exc_type, exc_val, exc_tb): # __exit__ 就是 finally 的面向对象实现 fcntl.flock(self.lock_file, fcntl.LOCK_UN) self.lock_file.close() # 使用 with FileLock("/data/report.csv") as lock: process_report() # 即使这里 raise 或 return,锁也必然释放注意:
finally块里return会覆盖try或except中的return,这是 Python 的明确规范。所以永远不要在finally里写return,除非你明确想劫持返回值——这通常是反模式。
3. 核心细节解析与实操要点:try/except/else/finally四重奏的实战密码
3.1else子句:那个被严重低估的“成功专用车道”
else在异常处理中存在感最低,文档里一句话带过:“else子句将在try子句没有引发异常时执行。” 但正是这个“没有引发异常”,让它成为隔离副作用、提升可测试性的黄金区域。
想象一个下载文件并校验 MD5 的函数:
# 常见错误写法:校验逻辑混在 try 里 def download_and_verify_bad(url, expected_md5): try: content = requests.get(url).content # 下载成功后立即校验 if hashlib.md5(content).hexdigest() != expected_md5: raise VerificationError("MD5 mismatch") return content except requests.RequestException as e: logger.error(f"Download failed: {e}") raise except VerificationError as e: logger.error(f"Verification failed: {e}") raise问题在哪?hashlib.md5(content).hexdigest()这行代码本身可能抛出异常(比如content是None,或内存不足),但它和网络错误无关,却被迫和requests异常共享同一个except分支。一旦这里出错,你会收到一个TypeError,但日志里却打印 “Download failed”,误导性极强。
正确解法是用else划清责任边界:
def download_and_verify_good(url, expected_md5): try: response = requests.get(url, timeout=30) response.raise_for_status() # 显式抛出 HTTPError except requests.RequestException as e: logger.error(f"Network error downloading {url}: {e}") raise DownloadError(f"Network failure: {e}") from e else: # 只有网络请求成功,才进入此块 # 这里的所有异常都属于“业务校验失败”,和网络无关 try: if hashlib.md5(response.content).hexdigest() != expected_md5: raise VerificationError("MD5 mismatch") return response.content except Exception as e: logger.error(f"Verification error for {url}: {e}") raise VerificationError(f"Invalid content: {e}") from e现在,else块里的hashlib错误会走VerificationError分支,日志清晰标明是“Verification error”,而不是混淆的“Network error”。更重要的是,else让单元测试变得极其简单:你可以 mockrequests.get返回成功响应,然后单独测试else块里的校验逻辑,无需模拟网络层。在我重构一个支付对账系统时,引入else后,校验模块的测试覆盖率从 62% 直接拉到 98%,因为所有网络依赖都被隔离在try里了。
3.2except的精细化匹配:从“抓大象”到“捉蚊子”
初学者常犯的错误是except:或except Exception:,这就像用渔网捞金鱼。Python 的异常继承体系是分层的,合理利用层级能让你的错误处理既精准又灵活。以文件操作为例:
# 错误:用 Exception 捕获一切 try: with open(path, "r") as f: data = f.read() except Exception as e: # 你不知道是路径错、权限错、编码错,还是磁盘坏了 handle_all_errors(e) # 正确:按需分层捕获 try: with open(path, "r", encoding="utf-8") as f: data = f.read() except FileNotFoundError: # 文件不存在:可能是用户输错名,或上游没生成 logger.warning(f"Config file not found: {path}, using defaults") return default_config() except PermissionError: # 权限不足:通常是部署问题,需运维介入 logger.critical(f"No read permission for {path}! Check file ownership.") raise except UnicodeDecodeError as e: # 编码错误:文件可能不是 UTF-8,或已损坏 logger.error(f"Encoding error in {path}: {e}") # 尝试用 latin-1 读取(它不会失败) with open(path, "r", encoding="latin-1") as f: return f.read() except OSError as e: # 其他操作系统错误:磁盘满、文件被占用等 if e.errno == errno.ENOSPC: alert_disk_full() elif e.errno == errno.EBUSY: logger.info(f"File {path} is busy, retrying...") time.sleep(1) return load_config(path) # 递归重试 else: raise # 其他未知 OSError,向上抛这个例子展示了三层捕获策略:
- 最具体的异常(
FileNotFoundError,PermissionError):做针对性处理,比如返回默认值、发告警、提示用户。 - 中层通用异常(
UnicodeDecodeError,OSError):处理一类相关错误,内部再用errno细分。 - 最宽泛的兜底(
except OSError as e:末尾的else: raise):确保未预见的OSError不被静默吞掉。
实操心得:我给自己定了一条铁律——每个
except块必须对应一个明确的业务动作,且这个动作在日志里有唯一标识。比如logger.warning("Config not found")和logger.critical("No permission")的日志级别、关键词都不同,这样运维用grep "critical"就能秒定位高危问题。
3.3raise的三种姿态:重抛、改造、新生
raise不是简单的“再抛一次”,它是异常处理的指挥棒,决定错误信息如何流转。我把它总结为三种使用场景:
原样重抛(
raise无参数):保持异常链完整
当你在except块里做了部分处理(如记录日志),但仍希望上游知道发生了什么,就用裸raise:try: result = risky_calculation() except ValueError as e: logger.debug(f"Calculation input invalid: {e}") raise # 重新抛出原异常,traceback 不变改造异常(
raise NewException(...) from e):添加上下文,不丢原始信息
这是专业代码的标志。比如一个底层数据库驱动抛psycopg2.OperationalError,你的业务层应该包装成DatabaseConnectionError,并保留原始异常作为__cause__:try: conn = psycopg2.connect(**config) except psycopg2.OperationalError as e: logger.error(f"Failed to connect to DB {config['host']}: {e}") # 用 from e 构建异常链 raise DatabaseConnectionError( f"Cannot reach database at {config['host']}" ) from e这样 traceback 会显示:
DatabaseConnectionError: Cannot reach database at db.example.com ... The above exception was the direct cause of the following exception: ... psycopg2.OperationalError: server closed the connection unexpectedly新生异常(
raise NewException(...)无from):彻底切断链路,声明新语义
当原始异常对调用方毫无意义,且你提供了更准确的业务解释时使用。例如,底层requests抛Timeout,但你的 API 规范要求统一返回ServiceUnavailableError:except requests.Timeout: raise ServiceUnavailableError("Third-party service is slow") # 不加 from,表示这是新的业务错误,与网络超时无关
关键区别:
from e会设置__cause__,raise ... from None会显式抑制__cause__(Python 3.3+),而裸raise会保留__context__(隐式上下文)。日常开发中,优先用from e,除非你有充分理由要切断链路。
4. 实操过程与核心环节实现:构建一个生产级的重试与降级框架
4.1 从零开始:一个带指数退避的 HTTP 请求装饰器
现在,我们把前面所有原则落地,写一个真实可用的@retry_on_failure装饰器。它要满足:可配置重试次数、指数退避、按异常类型选择性重试、失败后降级、全程结构化日志。
import time import random import logging from functools import wraps from typing import Callable, Any, Type, Union, Optional logger = logging.getLogger(__name__) def retry_on_failure( max_retries: int = 3, base_delay: float = 1.0, jitter: bool = True, exceptions: tuple[Type[Exception], ...] = (Exception,), fallback: Optional[Callable[[], Any]] = None ): """ 装饰器:对函数进行指数退避重试 Args: max_retries: 最大重试次数(不含首次尝试) base_delay: 基础延迟秒数,第n次重试等待 base_delay * (2^(n-1)) jitter: 是否添加随机抖动(避免雪崩) exceptions: 需要重试的异常类型元组 fallback: 重试全部失败后的降级函数 """ def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: last_exception = None for attempt in range(max_retries + 1): # +1 包含首次尝试 try: result = func(*args, **kwargs) if attempt > 0: logger.info( f"Function '{func.__name__}' succeeded on retry #{attempt}" ) return result except exceptions as e: last_exception = e if attempt < max_retries: # 还有重试机会 # 计算退避时间:base_delay * 2^attempt delay = base_delay * (2 ** attempt) if jitter: delay *= random.uniform(0.8, 1.2) logger.warning( f"Attempt {attempt + 1}/{max_retries + 1} failed for " f"'{func.__name__}': {type(e).__name__}: {e}. " f"Retrying in {delay:.2f}s..." ) time.sleep(delay) else: logger.error( f"All {max_retries + 1} attempts failed for " f"'{func.__name__}': {type(e).__name__}: {e}" ) # 所有重试都失败了 if fallback is not None: logger.info(f"Using fallback function for '{func.__name__}'") return fallback() else: # 重新抛出最后一次异常 raise last_exception return wrapper return decorator # 使用示例 @retry_on_failure( max_retries=2, base_delay=0.5, exceptions=(requests.Timeout, requests.ConnectionError), fallback=lambda: {"status": "degraded", "data": []} ) def fetch_user_data(user_id: str) -> dict: response = requests.get(f"https://api.example.com/users/{user_id}", timeout=5) response.raise_for_status() return response.json()这个装饰器的关键设计点:
- 异常类型白名单:只重试
Timeout和ConnectionError,对HTTPError(如 404)不重试,因为这是业务错误,重试无意义。 - 指数退避公式:
delay = base_delay * (2 ** attempt),第一次失败后等 0.5s,第二次等 1.0s,第三次等 2.0s,避免瞬间打爆下游。 - Jitter 随机化:乘以
0.8~1.2的随机因子,防止所有客户端在同一时刻重试,造成“重试风暴”。 - 结构化日志:每次重试都记录
attempt、exception type、delay,便于监控和分析。
4.2 进阶实战:结合contextlib.suppress和warnings的轻量级防御
有时你不需要重试,只需要“安静地忽略某些已知的、无害的异常”。contextlib.suppress就是为此而生:
from contextlib import suppress import warnings # 场景:删除临时文件,如果文件不存在,就当没发生 with suppress(FileNotFoundError): os.remove("/tmp/temp_cache.dat") # 场景:读取一个可能不存在的配置项,用默认值 config_value = "default" with suppress(KeyError): config_value = config_dict["advanced_feature_enabled"] # 结合 warnings:将旧版 API 的弃用警告转为异常,强制升级 def legacy_api_call(): warnings.warn( "legacy_api_call is deprecated, use new_api_call instead", DeprecationWarning, stacklevel=2 ) # ... old implementation ... # 在测试中捕获警告 import warnings with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") legacy_api_call() assert len(w) == 1 assert issubclass(w[0].category, DeprecationWarning)suppress的优势在于:它比try: ... except SomeError: pass更语义化,且性能略优(无异常栈帧开销)。而warnings模块则是处理“软性弃用”的标准方式,比直接raise DeprecationWarning更友好。
4.3 生产环境必加:全局异常处理器与 Sentry 集成
单个函数的try再完美,也挡不住未捕获的异常。因此,每个生产服务都必须有全局兜底。以 Flask 为例:
from flask import Flask, jsonify, request import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration app = Flask(__name__) # 初始化 Sentry(需提前配置 DSN) sentry_sdk.init( dsn="https://xxx@o123.ingest.sentry.io/123", integrations=[FlaskIntegration()], traces_sample_rate=1.0, ) @app.errorhandler(Exception) def handle_unexpected_error(e): # 1. 记录完整 traceback 到本地日志 logger.exception("Unhandled exception in request %s %s:", request.method, request.url) # 2. 发送结构化事件到 Sentry sentry_sdk.capture_exception(e) # 3. 返回用户友好的错误页(避免泄露敏感信息) return jsonify({ "error": "Internal Server Error", "request_id": request.headers.get("X-Request-ID", "unknown") }), 500 # 特别处理 404,不发 Sentry(这是预期行为) @app.errorhandler(404) def handle_not_found(e): return jsonify({"error": "Resource not found"}), 404这个全局处理器的核心是:日志、监控、用户响应三者分离。本地日志用于快速排查,Sentry 用于长期趋势分析和告警,而返回给用户的 JSON 则严格脱敏,不包含任何技术细节。
5. 常见问题与排查技巧实录:那些年我们踩过的异常处理深坑
5.1 常见问题速查表
| 问题现象 | 根本原因 | 快速诊断方法 | 解决方案 |
|---|---|---|---|
except块没执行,程序直接崩溃 | 捕获了错误的异常类型(如except ValueError:但实际抛出TypeError) | 在try块开头加print("in try"),在except开头加print("in except"),确认是否进入 | 用except Exception as e:临时捕获,打印type(e),再精确匹配 |
finally里的代码没运行 | try或except块中执行了os._exit()或sys.exit()(它们绕过finally) | 检查代码中是否有os._exit()(多线程中禁用)或sys.exit()(会触发SystemExit,但finally仍会执行) | 用return或raise替代sys.exit();绝对不用os._exit()除非 fork 后子进程 |
日志里只看到Exception,看不到具体类型 | except Exception:吞掉了原始异常,且没打印type(e) | 在except块中print(repr(e))或logging.exception() | 改为except Exception as e:并logger.exception("msg"),或用更具体的异常类 |
| 重试逻辑无限循环 | 重试条件判断错误(如while not success:但success永远不更新) | 在循环内加计数器和print(f"Retry {i}") | 用for attempt in range(max_retries):代替while,确保有限次 |
raise from e报SyntaxError | Python 版本低于 3.3(raise ... from是 3.3 新增语法) | 运行python --version | 升级 Python,或改用raise NewException(...)(不链) |
5.2 独家避坑技巧:来自血泪教训的 5 条军规
永远不要在
except里写pass
我见过最离谱的代码是except: pass,它像黑洞一样吞噬一切。即使你真想忽略某个错误,也必须写明原因:except FileNotFoundError: # Config optional, skip。否则,半年后你忘了这个pass的存在,它就会在某个深夜把你的线上服务拖垮。try块要尽可能小,只包裹可能出错的最小代码单元
错误示范:try: a = expensive_calc(); b = call_api(); c = save_to_db(a, b); return c except: ...。这里expensive_calc()失败了,但call_api()和save_to_db()的资源(如数据库连接)可能已分配却未释放。正确做法是分层try:try: a = calc() except: ...,try: b = api() except: ...,再try: save() except: ...。自定义异常必须继承
Exception,且命名以Error结尾
这是 PEP 8 的硬性规定。class ValidationError(Exception):是对的,class ValidationException(BaseException):是错的(BaseException是SystemExit的父类,不该被业务代码抛)。命名Error而不是Exception,是为了和标准库保持一致(ValueError,TypeError),让其他开发者一眼识别。在
finally里做资源清理时,用try/except包裹清理代码本身
清理操作也可能失败!比如file.close()在磁盘满时可能抛OSError。所以:f = open("log.txt", "w") try: f.write("data") finally: try: f.close() except OSError as e: logger.error(f"Failed to close log file: {e}") # 这里可以做最后的补救,如 flush buffer 到内存单元测试必须覆盖
except和finally分支
用pytest的monkeypatch模拟异常:def test_download_with_network_error(monkeypatch): # 模拟 requests.get 抛出 Timeout monkeypatch.setattr("requests.get", lambda *a, **k: (_ for _ in ()).throw(requests.Timeout)) with pytest.raises(DownloadError): fetch_user_data("123")
6. 最后分享一个真实场景:如何用异常处理让一个“必挂”的定时任务活过一年
去年我接手了一个每小时跑一次的报表生成任务,它要从 5 个不同数据库拉取数据,合并后生成 PDF。前任写的代码是这样的:
# 原始代码(已脱敏) def generate_report(): data1 = db1.query("SELECT ...") # 可能超时 data2 = db2.query("SELECT ...") # 可能连接失败 data3 = db3.query("SELECT ...") # 可能返回空 merged = merge(data1, data2, data3) # 可能 KeyError pdf = render_pdf(merged) # 可能内存溢出 send_email(pdf) # 可能 SMTP 错误这个函数上线后,平均每周挂三次,每次都要人工登录服务器重启。我用异常处理重构后,它稳定运行了 13 个月:
- 分层重试:对每个
dbX.query()加@retry_on_failure(exceptions=(Timeout, ConnectionError)),失败后返回空列表,不影响整体流程。 - 优雅降级:
merge()函数里,如果data3为空,就用data1和data2的组合生成简化版报表,并在 PDF 页脚加注“数据源 X 不可用”。 - 资源隔离:每个数据库查询都在独立的
with get_db_conn() as conn:里,确保连接一定释放。 - 最终兜底:整个函数外层加
try...except Exception:,记录完整 traceback 到 Sentry,并发送企业微信告警“报表生成失败,请检查 DB3 连接”,同时自动触发一个“生成昨日简化版报表”的备用任务。 - 健康检查:在 crontab 里加
*/5 * * * * /usr/bin/python3 /opt/check_report.py,检查/tmp/last_report.pdf的修改时间,超 70 分钟没更新就发告警。
现在,这个任务不再是一个“定时炸弹”,而是一个“自愈系统”。DB3 断连时,它默默生成简化报表;PDF 渲染内存不足时,它切到文本格式邮件;甚至整个服务器磁盘快满时,它还能把日志压缩归档后继续跑。这一切,都始于对try...except...else...finally这四个关键字的敬畏与精妙运用——它们不是代码里的补丁,而是你赋予程序的生存本能。