notFound与onError机制

要点

  • app.notFound()app.onError() 是 Hono 全局注册的两个回调函数,分别处理「未匹配路由」和「处理过程中抛出的异常」
  • 不注册时 Hono 有内置默认行为,返回纯文本响应
  • 两者都只接受一个回调,后注册的会覆盖先注册的
  • app.notFound 在路由匹配阶段结束时触发;app.onError 在中间件或处理函数抛异常时触发
  • HTTPException 默认不经过 app.onError,除非显式让它冒泡
  • 自定义 404 和全局错误处理的位置在顶层 app,不在子路由

1. 默认行为:什么都不注册时会发生什么

在讨论注册机制之前,先明确 Hono 在没有任何自定义处理时的默认行为。

当一个请求进来,经过所有中间件和路由都没有匹配到时,Hono 内部会走到 compose 流程的末尾。此时 context.finalized 仍为 false(没有任何处理函数调用过 c.json()c.text() 返回响应),Hono 会执行默认的 404 处理:

// 默认行为等价于
app.notFound((c) => {
  return c.text('404 Not Found', 404)
})

响应是纯文本 404 Not Found,状态码 404,Content-Type 为 text/plain

如果路由匹配成功,但处理函数内部抛出了未被捕获的异常,Hono 会执行默认的错误处理:

// 默认行为等价于
app.onError((err, c) => {
  console.error(err)
  return c.text('Internal Server Error', 500)
})

响应是纯文本 Internal Server Error,状态码 500。同时会在控制台打印原始错误堆栈。

这两个默认处理函数定义在 Hono 的 Hono 类构造函数中。理解这一点很重要——它们不是「没有处理」,而是「有默认处理,只是过于简陋」。

2. app.notFound() 的注册机制

注册方式

app.notFound() 接受一个回调函数,签名为 (c: Context) => Response | Promise<Response>

// app.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
app.notFound((c) => {
  return c.json(
    {
      success: false,
      error: 'Not Found',
      path: c.req.path,
    },
    404
  )
})

这个回调被存储在 Hono 实例的 notFoundHandler 属性上。源码层面大致是这样的:

// hono.ts(简化)
class Hono {
  notFoundHandler: NotFoundHandler = (c) => {
    return c.text('404 Not Found', 404)
  }
 
  notFound(handler: NotFoundHandler) {
    this.notFoundHandler = handler
    return this
  }
}

覆盖行为

每次调用 app.notFound() 都会直接替换掉之前的处理函数。这意味着:

app.notFound(handlerA)
app.notFound(handlerB) // handlerA 被覆盖,只有 handlerB 生效

不会有回调链,不会合并。如果你用 app.route() 挂载了子路由,子路由里也注册了 notFound,最终只有顶层 appnotFoundHandler 会生效。这是因为 notFoundHandler 只从 app 实例上读取,不会沿路由树查找。

触发时机

notFoundHandlerapp.fetch() 内部被调用。简化后的执行流程:

// 简化流程
async function fetch(request: Request) {
  const context = new Context(request)
 
  // 1. 执行中间件链和路由匹配
  const composed = compose(middlewares, routes)
  const response = await composed(context)
 
  // 2. 如果没有任何处理函数产生响应(context.finalized === false)
  if (!context.finalized) {
    // 触发 notFoundHandler
    return notFoundHandler(context)
  }
 
  return response
}

context.finalized 是关键判断条件。只要有任何一个中间件或路由处理函数调用了 c.json()c.text()c.html() 等方法设置了响应,finalized 就会变成 truenotFoundHandler 就不会触发。

换句话说,notFoundHandler 只在「整个中间件链执行完毕,没有任何人产出响应」的情况下才会被调用。

3. app.onError() 的注册机制

注册方式

app.onError() 接受一个回调函数,签名为 (err: Error, c: Context) => Response | Promise<Response>

// app.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
app.onError((err, c) => {
  console.error(`[Error] ${err.message}`)
  return c.json(
    {
      success: false,
      error: 'Internal Server Error',
    },
    500
  )
})

notFoundHandler 类似,errorHandler 也存储在 Hono 实例上,后注册的覆盖先注册的。

触发时机

onError 的触发点在 compose 函数内部。当中间件链中的任何一个处理函数抛出异常时,异常会沿着 await 链向上传播,最终被 compose 的 try-catch 捕获:

// compose 内部(简化)
async function composed(context) {
  try {
    // 依次执行中间件和路由处理函数
    await next()
  } catch (err) {
    // 异常被捕获,调用 errorHandler
    context.finalized = false
    return errorHandler(err, context)
  }
}

关键点:onError 捕获的是同步抛出和 async 拒绝两种情况。无论你在处理函数里 throw new Error() 还是 return Promise.reject(),最终都会被 onError 接到。

与中间件 try-catch 的关系

如果你在某个中间件里写了 try-catch,并且捕获了异常但没有重新抛出,那么 onError 不会被触发:

// 中间件吞掉了异常,onError 不会触发
app.use('*', async (c, next) => {
  try {
    await next()
  } catch (err) {
    // 这里捕获了异常,但没 throw
    console.error(err)
    return c.json({ error: 'handled locally' }, 500)
  }
})

只有当异常一路冒泡到 compose 层的 catch 时,onError 才会介入。这和 09 篇讲的错误冒泡机制是一致的。

4. 触发时机在请求处理流程中的位置

把整个请求处理流程画出来,标注 notFoundonError 的位置:

请求进入
  │
  ▼
app.fetch(request)
  │
  ▼
创建 Context
  │
  ▼
执行 compose(中间件链 + 路由匹配)
  │
  ├── 正常执行完毕,context.finalized = true ──▶ 返回响应
  │
  ├── 正常执行完毕,context.finalized = false ──▶ 触发 notFoundHandler
  │
  └── 执行过程中抛出异常 ──▶ 触发 errorHandler(onError)
                              │
                              ▼
                          返回错误响应

两个处理器在流程中是互斥的分支:

  • 没有异常,但没有匹配到路由notFoundHandler
  • 有异常errorHandler
  • 正常响应 → 两个都不触发

这里有一个容易混淆的点:notFoundHandler 不是异常,它是「正常执行完毕但没有产出响应」的状态。Hono 不会为此抛异常,而是用一个专门的分支来处理。

5. HTTPException 与 onError 的协作

默认行为:HTTPException 不经过 onError

在 09 篇中提到,HTTPException 可以通过 app.onError 统一处理。但这里要补充一个 Hono 内部的细节:当处理函数抛出 HTTPException 时,Hono 的 compose 层有专门的处理逻辑

// compose 内部(简化)
try {
  await next()
} catch (err) {
  if (err instanceof HTTPException) {
    // HTTPException 直接生成响应,不经过 errorHandler
    return err.getResponse()
  }
  // 其他异常才走 errorHandler
  return errorHandler(err, context)
}

也就是说,HTTPException 默认会直接调用自身的 getResponse() 方法生成响应,跳过 app.onError。这是一个有意的设计——HTTPException 已经携带了完整的状态码和响应信息,不需要再经过错误处理器加工。

让 HTTPException 经过 onError

如果你的项目需要对所有错误(包括 HTTPException)做统一格式化,比如加上请求 ID、时间戳等字段,可以修改 onError 的判断逻辑。但问题是 compose 层已经把 HTTPException 拦截了。

一种实用的做法是:不直接抛 HTTPException,而是抛自定义异常,让所有异常都走 onError

// errors.ts
// 不继承 HTTPException,继承普通 Error
export class AppError extends Error {
  constructor(
    public status: number,
    message: string,
    public code?: string
  ) {
    super(message)
    this.name = 'AppError'
  }
}
// app.ts
app.onError((err, c) => {
  if (err instanceof AppError) {
    return c.json(
      {
        success: false,
        error: err.message,
        code: err.code,
        timestamp: new Date().toISOString(),
      },
      err.status
    )
  }
 
  console.error(err)
  return c.json(
    {
      success: false,
      error: 'Internal Server Error',
      timestamp: new Date().toISOString(),
    },
    500
  )
})
// route.ts
import { AppError } from './errors'
 
app.get('/users/:id', (c) => {
  const user = findUser(c.req.param('id'))
  if (!user) {
    throw new AppError(404, 'User not found', 'USER_NOT_FOUND')
  }
  return c.json({ success: true, data: user })
})

这样所有业务异常和系统异常都统一经过 onError,响应格式完全一致。HTTPException 在这个方案里被完全替代了。

另一种方案:在中间件层拦截 HTTPException

如果你既想用 HTTPException 的便捷,又想统一走 onError,可以在最外层加一个中间件,在中间件层捕获 HTTPException 并转为普通异常重新抛出:

// 最外层中间件
app.use('*', async (c, next) => {
  try {
    await next()
  } catch (err) {
    if (err instanceof HTTPException) {
      // 转为 AppError 重新抛出,让 onError 处理
      throw new AppError(err.status, err.message)
    }
    throw err
  }
})

这种方式不需要改路由代码,只需在入口处加一层转换。但要注意中间件的注册顺序——这个转换中间件必须在所有路由之前注册。

6. 自定义 404 页面

JSON 格式

API 服务最常用的 404 格式,返回请求路径方便前端排查:

app.notFound((c) => {
  return c.json(
    {
      success: false,
      error: 'Not Found',
      path: c.req.path,
      method: c.req.method,
    },
    404
  )
})

前端收到的响应:

{
  "success": false,
  "error": "Not Found",
  "path": "/api/v1/nonexistent",
  "method": "GET"
}

HTML 格式

如果 Hono 同时服务前端页面(SSR 或混合渲染),可以返回 HTML 格式的 404 页面:

import { html } from 'hono/html'
 
app.notFound((c) => {
  return c.html(
    html`<!DOCTYPE html>
      <html lang="zh-CN">
        <head>
          <meta charset="UTF-8" />
          <title>404 - 页面不存在</title>
        </head>
        <body>
          <h1>404</h1>
          <p>找不到页面:${c.req.path}</p>
          <a href="/">返回首页</a>
        </body>
      </html>`,
    404
  )
})

根据 Accept 头自动选择格式

更完善的方案是根据客户端的 Accept 请求头自动选择返回 JSON 还是 HTML:

app.notFound((c) => {
  const accept = c.req.header('accept') || ''
 
  if (accept.includes('application/json')) {
    return c.json(
      {
        success: false,
        error: 'Not Found',
        path: c.req.path,
      },
      404
    )
  }
 
  return c.html(
    html`<!DOCTYPE html>
      <html lang="zh-CN">
        <head>
          <meta charset="UTF-8" />
          <title>404</title>
        </head>
        <body>
          <h1>页面不存在</h1>
          <p>请求的路径 ${c.req.path} 没有找到。</p>
          <a href="/">返回首页</a>
        </body>
      </html>`,
    404
  )
})

这种内容协商的思路在服务端渲染项目中很常见。API 客户端发 Accept: application/json 拿到 JSON,浏览器直接访问拿到 HTML 页面。

7. 全局错误处理的最佳实践

统一错误响应格式

无论什么类型的错误,前端拿到的 JSON 结构应该一致:

interface ErrorResponse {
  success: false
  error: string
  code?: string
  details?: Record<string, string[]>
  path?: string
}

分级处理策略

onError 里按错误类型分级处理:

// app.ts
app.onError((err, c) => {
  const path = c.req.path
 
  // 1. 业务异常:已知的、可预期的错误
  if (err instanceof ValidationError) {
    return c.json(
      {
        success: false,
        error: err.message,
        code: 'VALIDATION_ERROR',
        details: err.details,
        path,
      },
      err.status
    )
  }
 
  if (err instanceof AuthError) {
    return c.json(
      {
        success: false,
        error: err.message,
        code: 'AUTH_ERROR',
        path,
      },
      err.status
    )
  }
 
  if (err instanceof AppError) {
    return c.json(
      {
        success: false,
        error: err.message,
        code: err.code,
        path,
      },
      err.status
    )
  }
 
  // 2. 系统异常:未预期的错误,需要记录日志
  console.error(`[UnhandledError] ${path}`, {
    message: err.message,
    stack: err.stack,
  })
 
  // 3. 生产环境不暴露内部错误细节
  return c.json(
    {
      success: false,
      error: 'Internal Server Error',
      path,
    },
    500
  )
})

这里的分级原则:

  • 业务异常(继承自自定义基类的):返回具体错误信息给前端,状态码由错误本身决定
  • 系统异常(其他所有 Error):只返回通用提示,详细错误信息只写日志,不暴露给外部

日志记录

onError 是集中记录错误日志的最佳位置。生产环境中建议结构化输出:

app.onError((err, c) => {
  const logPayload = {
    timestamp: new Date().toISOString(),
    method: c.req.method,
    path: c.req.path,
    requestId: c.get('requestId'),
    errorMessage: err.message,
    errorStack: err.stack,
  }
 
  // 结构化日志,方便日志系统采集和检索
  console.error(JSON.stringify(logPayload))
 
  return c.json(
    { success: false, error: 'Internal Server Error' },
    500
  )
})

把请求路径、请求 ID、错误堆栈放在一起输出,排查问题时能快速定位到具体请求。

注册顺序

notFoundonError 的注册位置没有强制要求(它们不是中间件,不参与 compose 链),但建议放在路由定义之前,让代码意图更清晰:

// app.ts
const app = new Hono()
 
// 1. 先注册全局处理器
app.notFound((c) => { /* ... */ })
app.onError((err, c) => { /* ... */ })
 
// 2. 再注册中间件和路由
app.use('*', logger())
app.route('/api', apiRoutes)

延伸阅读

总结

app.notFound()app.onError() 是 Hono 请求处理流程末端的两个兜底回调。notFound 在「没有异常但也没有产出响应」时触发;onError 在「执行过程中抛出异常」时触发。两者互斥,不会同时执行。

HTTPException 默认由 compose 层直接处理,不经过 onError。如果需要所有错误统一格式化,可以用自定义异常类替代 HTTPException,或者在最外层中间件做转换。

注册方式都是直接赋值覆盖,后注册的替换先注册的。自定义 404 可以根据 Accept 头做内容协商,返回 JSON 或 HTML。全局错误处理应按错误类型分级:业务异常返回具体信息,系统异常只返回通用提示并记录日志。

下一篇聊 Hono 的路由匹配引擎——正则树是如何实现高效路由分发的。