404处理
要点
- 请求没有命中任何路由时,Hono 默认返回
404 Not Found纯文本响应 app.notFound()可以自定义 404 响应格式,但只对顶层 app 生效- 路由没匹配到(框架级 404)和资源不存在(业务级 404)是两件事,不要混在一起
- 通配符
*可以做兜底路由,SPA 场景下用它来 fallback 到 index.html - 尾部斜杠、大小写、basePath 配置错误是 unexpected 404 的常见来源
- 404 日志能帮助发现前端错误链接和 API 调用问题,值得投入监控
1. 默认 404 行为
当一个请求的路径没有匹配到任何已注册的路由时,Hono 返回纯文本响应:
404 Not Found状态码 404,Content-Type 是 text/plain,body 只有这一行字符串。没有 JSON,没有 HTML,没有任何业务信息。前端拿到这个响应后,无法区分「路径写错了」还是「服务本身有问题」。
先看一个最简单的 app:
// index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/users', (c) => c.json({ users: [] }))
export default app请求 GET /posts 会命中默认 404,因为 /posts 没有注册过。
2. app.notFound():自定义 404 响应
app.notFound() 接收一个回调函数,签名和路由处理函数一样。当没有路由匹配时,Hono 调用这个回调而不是返回默认纯文本:
// index.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
)
})
app.get('/users', (c) => c.json({ users: [] }))
export default app请求 GET /posts 返回:
{ "success": false, "error": "Not Found", "path": "/posts" }c.req.path 把请求路径放进响应,调试时可以直接确认路径是否正确,不用翻请求日志。
这里有一个重要的边界:
app.notFound:没有任何路由匹配,请求连路由处理函数都没进HTTPException(404):路由匹配到了,但业务逻辑判断资源不存在(如数据库查不到用户 ID)
两者的触发时机完全不同。HTTPException 的用法在 03.09 错误处理那篇展开过,这一篇聚焦在框架级别的 404。
app.notFound 只对顶层 app 生效
app.notFound() 注册在子 app 上不会报错,但挂载后不生效。子 app 内部的未匹配路由会向上冒泡到顶层 app,由 app.notFound 处理:
// users.ts — 子 app 的 notFound 只在独立使用时生效
const users = new Hono()
users.notFound((c) => c.json({ error: 'users: not found' }, 404))
users.get('/', (c) => c.json({ users: [] }))// index.ts — 挂载后,404 统一由顶层 app.notFound 处理
const app = new Hono()
app.route('/users', users)
app.notFound((c) => c.json({ error: 'Not Found', path: c.req.path }, 404))请求 GET /users/nonexistent 时,users 模块内部没有匹配的路由,但不会触发 users.notFound——它冒泡到顶层 app,由 app.notFound 处理。
app.notFound 是整个应用最后的 404 出口,注册在顶层 app 上才能兜住所有未匹配的路径。
3. API 与 HTML 场景下的 404 格式
API 场景:结构化 JSON
// index.ts
app.notFound((c) => {
return c.json(
{
success: false,
error: 'Route not found',
path: c.req.path,
method: c.req.method,
},
404
)
})把 method 也放进响应,能区分「GET /users 不存在」和「POST /users 不存在」——前者可能是路径错误,后者可能是方法用错了。
HTML 场景:按 Accept 协商
// index.ts
app.notFound((c) => {
const accept = c.req.header('Accept') ?? ''
if (accept.includes('text/html')) {
return c.html('<html><body><h1>404</h1></body></html>')
}
return c.json({ success: false, error: 'Not Found', path: c.req.path }, 404)
})API 调用方带 Accept: application/json,浏览器带 Accept: text/html,同一个回调服务两种客户端。
4. 兜底路由:通配符 *
通配符 * 注册一个兜底路由,效果类似 app.notFound() 但更灵活——它是真正的路由处理函数,可以挂载中间件、做条件判断。
// index.ts
app.get('/users', (c) => c.json({ users: [] }))
app.get('/posts', (c) => c.json({ posts: [] }))
// 兜底路由必须放在所有具体路由之后
app.all('*', (c) => {
return c.json(
{ success: false, error: `${c.req.method} ${c.req.path} not found` },
404
)
})通配符兜底和 app.notFound() 的区别:
app.notFound()更简洁,适合只需要自定义响应格式的场景- 通配符
*可以挂载中间件,做条件判断 app.get('*', ...)只兜底 GET 请求,需要兜底所有方法用app.all('*', ...)
兜底路由必须放在所有具体路由之后,否则后面的路由永远不会被匹配。
SPA Fallback
单页应用的客户端路由可能请求任意路径(如 /dashboard),这些路径在服务端没有对应路由。服务端需要返回 index.html 让客户端路由接管:
// index.ts
app.get('/api/users', (c) => c.json({ users: [] }))
app.get('/api/posts', (c) => c.json({ posts: [] }))
app.use('/assets/*', serveStatic({ root: './' }))
app.get('*', (c) => {
const path = c.req.path
// API 请求返回 JSON 404
if (path.startsWith('/api/')) {
return c.json({ success: false, error: 'API not found', path }, 404)
}
// 其他路径返回 index.html(实际项目中读取文件内容)
return c.html('<!DOCTYPE html><!-- index.html -->')
})按路径前缀区分 API 和 SPA 路由。/api/ 开头的是接口请求,返回 JSON 错误;其他路径交给客户端路由处理。
5. 嵌套路由中的 404
使用 app.route() 挂载子 app 时,404 的触发条件需要结合挂载路径来看:
// users.ts
const users = new Hono()
users.get('/', (c) => c.json({ users: [] }))
users.get('/:id', (c) => c.json({ id: c.req.param('id') }))
export default users// index.ts
const app = new Hono()
app.route('/users', users)
app.notFound((c) => c.json({ error: 'Not Found', path: c.req.path }, 404))| 请求路径 | 结果 |
|---|---|
GET /users/ | 命中 GET /,返回 200 |
GET /users/42 | 命中 GET /:id,返回 200 |
GET /users/42/posts | 未匹配,触发 app.notFound,404 |
GET /posts | 未匹配,触发 app.notFound,404 |
POST /users/ | 未注册 POST,触发 404 |
GET /users/42/posts 有两个路径段,不匹配 /:id(只匹配一段),也不匹配 /,所以未命中。需要匹配更深层路径的话,应该在子模块里继续定义:
// users.ts
users.get('/:id/posts', (c) => {
return c.json({ userId: c.req.param('id'), posts: [] })
})子 app 独立运行时的 404
子 app 可以独立测试。独立运行时,子 app 自己的 notFound 生效;挂载到主 app 后,由顶层 notFound 接管。这让子 app 在测试和独立部署时都能有合适的 404 响应。
6. 常见 unexpected 404 来源
尾部斜杠
Hono 严格匹配路径字符串,/users 和 /users/ 是两个不同的路径:
app.get('/users', (c) => c.json({ users: [] }))
// GET /users → 200 ✅
// GET /users/ → 404 ❌(尾部斜杠)这和 Nginx 的默认行为不同——Nginx 通常会自动重定向。Hono 不做这个处理,可以用中间件规范化:
// middleware/trailing-slash.ts
export const stripTrailingSlash = createMiddleware(async (c, next) => {
if (c.req.path !== '/' && c.req.path.endsWith('/')) {
const url = new URL(c.req.url)
url.pathname = c.req.path.replace(/\/+$/, '')
return c.redirect(url.toString(), 301)
}
await next()
})大小写敏感
Hono 路由匹配大小写敏感。/Users 和 /users 是不同的路径:
app.get('/users', (c) => c.json({ users: [] }))
// GET /users → 200 ✅
// GET /Users → 404 ❌API 路径约定使用全小写。如果调用方有时传大写路径,可以在中间件里做路径规范化,或在文档里明确说明。
basePath 配置
basePath 会改变所有路由的实际路径。前端请求没包含前缀时直接 404:
const app = new Hono().basePath('/api/v1')
app.get('/users', (c) => c.json({ users: [] }))
// GET /api/v1/users → 200 ✅
// GET /users → 404 ❌(缺少 basePath 前缀)
// GET /api/users → 404 ❌(版本路径不对)这类问题在版本升级时容易出现——前端还在请求 /api/v1/users,后端已切到 /api/v2/users。在 404 响应里带上 path 信息能帮助快速定位。
7. Soft 404:路由匹配但资源不存在
还有一种情况是路由匹配到了,但业务逻辑认为资源不存在。框架层面请求命中了路由,业务层面资源并不存在。
// users.ts
import { HTTPException } from 'hono/http-exception'
const db: Record<string, { id: string; name: string }> = {
'1': { id: '1', name: 'Alice' },
}
app.get('/users/:id', (c) => {
const user = db[c.req.param('id')]
if (!user) {
throw new HTTPException(404, { message: 'User not found' })
}
return c.json(user)
})请求 GET /users/999 命中 /:id 路由,但 db['999'] 不存在,抛出 HTTPException(404)。这个 404 不触发 app.notFound——它走错误处理链路,由 app.onError 捕获。
区分两种 404 对前端有影响:
- 框架级 404:路径本身不存在,通常是链接错误或 API 配置错误
- 业务级 404:路径正确,但资源已删除或从未创建
前端处理策略不同——前者需要检查链接生成逻辑,后者需要显示「资源不存在」的提示。
8. 404 日志与监控
404 日志能帮助发现前端错误链接、爬虫扫描、API 调用方的路径错误。
在 notFound 中记录日志
// index.ts
app.notFound((c) => {
console.warn(JSON.stringify({
level: 'warn',
message: 'Route not found',
method: c.req.method,
path: c.req.path,
referer: c.req.header('Referer') ?? '-',
userAgent: c.req.header('User-Agent') ?? '-',
}))
return c.json({ success: false, error: 'Not Found', path: c.req.path }, 404)
})Referer 在排查时特别有用——大量 404 来自同一个 Referer 通常是前端某处链接写错了。User-Agent 帮助识别爬虫扫描,这类 404 通常不需要关注。
用中间件统计 404 频率
// middleware/404-counter.ts
const notFoundCount: Record<string, number> = {}
export const track404 = createMiddleware(async (c, next) => {
await next()
if (c.res.status === 404) {
notFoundCount[c.req.path] = (notFoundCount[c.req.path] ?? 0) + 1
}
})await next() 之后检查 c.res.status,404 时记录一次。实际项目中把 notFoundCount 换成 Prometheus counter 或发到日志采集服务。
9. 测试 404 响应
测试自定义 notFound
// tests/not-found.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
const app = new Hono()
app.notFound((c) => c.json({ success: false, error: 'Not Found', path: c.req.path }, 404))
app.get('/users', (c) => c.json({ users: [] }))
describe('自定义 404', () => {
it('未注册路径返回 JSON 404', async () => {
const res = await app.request('/nonexistent')
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ success: false, error: 'Not Found', path: '/nonexistent' })
})
it('未注册方法返回 404', async () => {
const res = await app.request('/users', { method: 'DELETE' })
expect(res.status).toBe(404)
})
})区分框架级与业务级 404
// tests/not-found-distinction.test.ts
const app = new Hono()
app.get('/users/:id', (c) => {
throw new HTTPException(404, { message: 'User not found' })
})
app.notFound((c) => c.json({ error: 'Route not found', path: c.req.path }, 404))
app.onError((err, c) => {
if (err instanceof HTTPException) return c.json({ error: err.message }, err.status)
return c.json({ error: 'Internal Server Error' }, 500)
})
describe('404 类型区分', () => {
it('路由未匹配 → notFound', async () => {
const res = await app.request('/nonexistent')
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: 'Route not found', path: '/nonexistent' })
})
it('路由匹配但资源不存在 → onError', async () => {
const res = await app.request('/users/999')
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: 'User not found' })
})
})/nonexistent 没有路由匹配,走 app.notFound;/users/999 匹配了 /:id 但抛出 HTTPException(404),走 app.onError。两条链路的响应格式可以不同。
延伸阅读
- 03.09-错误冒泡与异常处理 —
HTTPException和app.onError的完整机制 - 01-基础路由 — 路由注册、参数解析和优先级规则
- 07-路由模块化 —
app.route()和basePath()的用法 - Hono Routing 文档 — Hono 官方路由指南
- Hono Error Handling 文档 — Hono 官方错误处理文档
总结
404 处理涉及两个层面:框架层面的「路由未匹配」和业务层面的「资源不存在」。Hono 分别提供了 app.notFound() 和 HTTPException(404) 来覆盖这两种情况,两者的触发时机和处理链路不同。
回顾这一篇涉及到的要点:
- 默认行为:无路由匹配时返回
404 Not Found纯文本 app.notFound():自定义 404 响应格式,只对顶层 app 生效- API vs HTML:通过
Acceptheader 做内容协商,同一回调服务不同客户端 - 兜底路由:通配符
*比app.notFound()更灵活,可以挂载中间件 - SPA fallback:用通配符路由返回
index.html,与 API 404 按路径前缀区分 - 嵌套路由:子 app 的 404 向上冒泡到顶层 app 的
notFound - 常见来源:尾部斜杠、大小写、basePath 配置是 unexpected 404 的高频原因
- Soft 404:路由匹配但资源不存在,走
HTTPException(404)→app.onError - 日志监控:在
notFound中记录 method、path、referer、user-agent - 测试:用
app.request()验证 404 状态码、响应格式和两种 404 的链路区别
下一篇进入路由测试——app.request() 的完整用法、中间件测试、依赖注入与 mock、快照测试和集成测试模式。