钩子回调里抛出异常后,整个页面崩了但日志只记到 `shutdown`:我整理的 WordPress 异常传播"断点地图"与兜底捕获方案
上周改一个支付回调插件,第三方支付通知过来时,我在 `woocommerce_payment_complete` 里抛了个 `RuntimeException` 想中断异常订单。结果前台返回 500,但 `debug.log` 里只有一条 PHP Fatal error: Uncaught RuntimeException...,后面跟着 WordPress 的 `shutdown` 动作栈,真正的业务上下文全丢了。更坑的是,异常没被我的 `try-catch` 包住——明明写在回调第一行。
WordPress 的钩子机制对异常并不友好。核心代码里大量使用 call_user_func_array 和 do_action,异常一旦在某个钩子里抛出,会直接把执行流甩出 WordPress 的"轨道",导致三种典型症状:
症状一:异常被"吞"在嵌套钩子里
如果你的回调里又触发了另一个 do_action,而那个动作里抛了异常,错误栈会断在第二层,第一层你写的日志可能根本刷不到磁盘。我踩的坑是支付完成钩子 → 触发积分发放钩子 → 积分钩子调了外部 API 超时 → Exception 飞出来,但日志里只显示积分钩子那层的栈。
症状二:set_error_handler 和异常处理器"打架"
很多插件为了兼容老 PHP,会注册自己的错误处理器。WordPress 本身在 wp-includes/load.php 里也有 wp_debug_mode() 的设置。当异常穿过这些边界时,哪个处理器先接到、会不会转码成 E_ERROR,完全看加载顺序。我见过最诡异的情况是:异常被转成致命错误后,error_get_last() 里类型是 E_ERROR,但 Exception 对象已经没了,想抓具体消息没门。
症状三:REST 请求和 AJAX 的"静默死亡"
后台 AJAX 走 admin-ajax.php,REST 走 rest_api.php,这两个入口的异常处理逻辑不一样。AJAX 里异常如果没被截,直接 500,前端 jQuery.ajax 进 error 回调,但响应体可能是 HTML 错误页;REST 接口从 WP 4.7 开始有 rest_handle_server_error,但只兜 WP_Error,你抛的 Exception 照样崩。
我现在的做法是在插件入口文件里加一个"地网"层,不依赖 WordPress 的钩子顺序:
// 插件主文件顶部,任何 hook 注册之前
add_action( 'plugins_loaded', function() {
set_exception_handler( function( $e ) {
// 先写一条结构化的,防止后面再崩
error_log( sprintf(
'[%s] %s | File: %s:%d | TraceHash: %s',
get_class( $e ),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
md5( $e->getTraceAsString() )
) );
// 如果是已知业务异常,尝试给前端体面响应
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
wp_send_json( [
'code' => 'internal_exception',
'message' => wp_get_environment_type() === 'production'
? 'Server error'
: $e->getMessage(),
'trace_hash' => md5( $e->getTraceAsString() ),
], 500 );
}
// 别拦真正的致命错误,交给 WordPress 或 Sentry 之类的
if ( $e instanceof Error ) {
throw $e; // 重新抛出,保持原有行为
}
// 业务 Exception 已记录,优雅结束
status_header( 500 );
exit;
} );
}, PHP_INT_MIN ); // 确保最早注册
几个实战细节:
1. shutdown 动作里补刀
异常处理器之后,WordPress 还会跑 shutdown。我在那再加一层保险,专门抓没被截住的致命错误,和异常记录做关联:
add_action( 'shutdown', function() {
$error = error_get_last();
if ( $error && in_array( $error['type'], [ E_ERROR, E_PARSE, E_COMPILE_ERROR ], true ) ) {
// 看看是不是刚才那个异常的"遗骸"
$trace_hash = md5( $error['message'] . $error['file'] . $error['line'] );
// 可以在这里发告警,或者写一条更完整的日志
}
} );
2. 给每个异步入口贴"标签"
AJAX、REST、Webhook、Cron 这几种场景,异常的处理策略完全不同。我在插件里定义了几个常量入口标识,异常处理器里根据标识决定是重试、告警还是直接忽略:
// webhook 入口文件顶部
define( 'MYPLUGIN_WEBHOOK_CONTEXT', 'stripe_webhook' );
// 异常处理器里
$context = defined( 'MYPLUGIN_WEBHOOK_CONTEXT' )
? MYPLUGIN_WEBHOOK_CONTEXT
: 'general';
if ( $context === 'stripe_webhook' ) {
// Stripe 要求返回 2xx,否则重试。不能 500。
http_response_code( 200 );
echo json_encode( [ 'received' => true, 'processed' => false ] );
exit;
}
3. 开发阶段用"异常快照"替代 var_dump
以前钩子里出问题就 var_dump 然后 die,但 WordPress 的钩子是树状结构,die 会断掉后面所有清理动作。现在我在开发环境注册一个"软异常"机制:
function myplugin_soft_throw( Exception $e ) {
do_action( 'myplugin_captured_exception', $e );
// 继续执行,不中断流程,但异常对象被收集到全局
$GLOBALS['myplugin_soft_exceptions'][] = $e;
}
页面底部再用 admin bar 或者 footer 钩子把收集到的异常打印出来,既不断流,又能看到完整上下文。
最后说一个反直觉的点:register_shutdown_function 和 WordPress 的 shutdown 动作执行顺序不确定。如果你的异常处理器里用了 exit,shutdown 可能不触发;如果不用 exit,执行流可能继续跑到下一个钩子,产生更诡异的状态。我目前的妥协是:业务 Exception 优雅结束,Error 重新抛出让原生机制处理,两边都不耽误。
你们是怎么处理钩子里的异常的?有没有遇到过异常被某个主题或者另一个插件的处理器"截胡"的情况?