4、自定义异常:创建自定义异常类、抛出异常、异常中携带额外信息

讲完了 Python 内置的那些异常,说实话,在实际运维脚本里,光靠它们远远不够。你想想看,一个自动化部署脚本跑着跑着,突然报了个 ValueError,你根本不知道是哪个环节出了问题——是配置文件格式不对?还是环境变量没设好?还是远程服务器返回了意料之外的响应?

这时候,自定义异常就派上用场了。说白了,就是给异常打上专属标签,让它一出现,你就知道「哦,是那个地方出事了」。

4.1 为什么要自己造异常?

我刚开始写自动化脚本那会儿,也偷懒,全靠内置异常撑着。直到有一次线上事故——一个批量重启服务的脚本,因为某个节点 SSH 连接超时,抛了个 TimeoutError,结果被上层一个通用的 except Exception 给吞了,整个脚本继续往下跑,最后把生产环境搞乱了。

嗯,从那以后,我养成了一个习惯:每个关键模块都定义自己的异常类。好处很明显:

  • 定位问题快:看到异常名就知道是哪个模块出的问题
  • 携带上下文:可以带上主机名、端口号、错误码等额外信息
  • 分层处理:不同层级的异常可以分别捕获,互不干扰

核心原则:自定义异常不是炫技,而是为了让你的脚本在出问题时,能给出最直接的线索。

4.2 创建自定义异常类

其实特别简单,继承 Exception 就行。我个人习惯是每个模块建一个 exceptions.py 文件,把相关的异常都放一起。

# exceptions.py
class SSHConnectionError(Exception):
    """SSH连接失败时抛出"""
    pass

class ConfigParseError(Exception):
    """配置文件解析失败时抛出"""
    pass

class CommandExecutionError(Exception):
    """远程命令执行异常"""
    pass

你看,就这么几行。但别小看它——有了这些类名,你的 try/except 块就能写得非常精准:

try:
    ssh_client.connect(host, port)
except SSHConnectionError as e:
    # 只处理SSH连接问题
    logger.error(f"SSH连接失败: {host}:{port}")
    alert_ops_team(host, port)

我在项目中遇到过一种情况:有人把所有异常都写成 CustomError,然后靠错误消息字符串来区分。结果代码越写越乱,最后根本没法维护。记住:异常类名就是最好的文档

4.3 抛出异常:raise 的正确姿势

抛出异常很简单,一个 raise 就搞定。但怎么抛、抛什么,这里头有讲究。

最基本的用法:

def check_config(config_path):
    if not os.path.exists(config_path):
        raise ConfigParseError(f"配置文件不存在: {config_path}")
    # 继续解析...

但更实用的做法是——在异常中携带额外信息。你想想看,如果只抛一个异常名,上层捕获后还得自己去查上下文,多麻烦。

class SSHConnectionError(Exception):
    def __init__(self, host, port, message=""):
        self.host = host
        self.port = port
        self.message = message or f"SSH连接失败: {host}:{port}"
        super().__init__(self.message)

# 使用
raise SSHConnectionError("192.168.1.100", 22, "认证超时")

这样上层捕获时,可以直接拿到主机和端口信息:

try:
    connect_to_host("192.168.1.100", 22)
except SSHConnectionError as e:
    print(f"请检查主机 {e.host}:{e.port} 的SSH服务状态")
    # 甚至可以自动重试其他节点

小技巧:我习惯在异常类里加一个 to_dict() 方法,方便序列化成 JSON 写入日志或告警系统。

4.4 异常中携带额外信息的最佳实践

光带主机和端口还不够,真实场景下,你可能需要携带更多信息。比如一个批量部署脚本,每个节点执行结果都不一样:

class DeployError(Exception):
    def __init__(self, host, service, exit_code, stderr):
        self.host = host
        self.service = service
        self.exit_code = exit_code
        self.stderr = stderr
        self.timestamp = datetime.now().isoformat()
        message = f"[{self.timestamp}] 部署失败: {host}/{service}, 退出码: {exit_code}"
        super().__init__(message)

    def to_dict(self):
        return {
            "host": self.host,
            "service": self.service,
            "exit_code": self.exit_code,
            "stderr": self.stderr,
            "timestamp": self.timestamp
        }

这样,上层捕获后可以直接把异常信息写入数据库或发送到监控平台:

try:
    deploy_service("web-01", "nginx")
except DeployError as e:
    # 写入告警系统
    alert_system.send(e.to_dict())
    # 记录详细日志
    logger.error(f"部署异常详情: {e.stderr}")

我曾经因为一个脚本没有携带足够信息,排查问题花了整整两个小时。后来加上了这些额外字段,再出问题,看一眼日志就知道该去哪个服务器、看哪个服务、查哪段输出。

4.5 异常链:别丢了根因

有时候,你的自定义异常是由另一个异常触发的。比如 SSH 连接失败,底层可能抛了个 socket.timeout。如果你直接 raise SSHConnectionError,原始异常就丢了。

Python 3 提供了 raise ... from ... 语法,可以保留异常链:

try:
    sock.connect((host, port))
except socket.timeout as e:
    raise SSHConnectionError(host, port, "连接超时") from e

这样,当你打印异常堆栈时,能看到完整的调用链:

SSHConnectionError: SSH连接失败: 192.168.1.100:22, 连接超时
  └─ 由 socket.timeout: timed out 引起

注意:不要滥用 raise ... from e。如果你只是把异常类型转换一下,但原因完全不同,就别用 from。否则会误导排查方向。

4.6 实战:一个完整的自定义异常体系

最后,我分享一个我在自动化运维平台里用的异常体系结构,供你参考:

异常类 携带信息 使用场景
SSHConnectionError host, port, username, error_msg SSH 连接失败
CommandTimeoutError host, command, timeout 远程命令执行超时
ConfigValidationError config_path, field, expected_type 配置文件校验失败
DeployRollbackError service, version, rollback_version, reason 部署回滚失败
HealthCheckError host, service, check_type, actual_value 健康检查不通过

每个异常类都遵循同样的模式:明确的类名 + 携带关键上下文 + 可序列化。这样,不管是写日志、发告警、还是做自动化恢复,都能拿到足够的信息。

一句话总结:自定义异常不是花架子,它是你脚本的「黑匣子」。出事的时候,它能告诉你最准确的事故原因。

下一节,我们会聊聊怎么把这些异常统一管理起来,配合日志系统,形成一个完整的异常监控闭环。嗯,到时候你会发现,前面这些准备工作,全都能派上用场。