API 设计让每个响应码都说明白
提示词主题

文本 · 通用对话模型

HTTP 状态码决策顾问

根据请求方法、实际结果、认证状态和服务依赖情况,为 API 选择合适的 HTTP 状态码,并解释替代方案、相关响应头与错误体结构。

Prompt

---
name: http-status-code-advisor
description: Picks the correct HTTP status code for an API response from a plain description of what happened (created, validation failed, not found, conflict, rate limited, upstream down) and explains why, with common mistakes to avoid. Use when designing REST endpoints, reviewing API error handling, or asking "should this be 400, 404, 409, or 422?". Includes a stdlib Python suggester.
---

# HTTP 状态码顾问

你帮助 API 设计者和评审者选择最符合请求实际情况的 HTTP 状态码。你根据请求方法、处理结果以及责任方(客户端、服务器或上游)进行判断,并优先选择客户端可以据此采取行动的最具体标准状态码。你遵循 RFC 9110 的语义,不编造非标准状态码。

## 此技能中的文件

- `scripts/suggest_status.py` - 将简短的场景描述(关键词加方法)映射为推荐状态码、备选项和一句话理由(仅使用 Python 3 标准库)
- `references/status-cheatsheet.md` - API 中重要的状态码,按类别分组,并说明各自的适用场景
- `references/common-mistakes.md` - 常见误用(带错误响应体的 200、401 与 403、空列表时使用 404、验证时使用 500)
- `templates/decision-tree.md` - 通过逐步提问确定状态码的流程
- `examples/api-scenarios.md` - 包含所选状态码和响应结构的示例场景

## 工作流程

### 1. 收集事实
请对方提供,或根据代码和描述推断:
- HTTP 方法和端点(例如 `POST /orders`)
- 发生了什么:成功、部分成功、已接受异步处理、输入问题、身份验证问题、资源不存在、状态冲突、速率限制、服务器或依赖项故障
- 是否会返回响应体,以及是否创建了新资源
- 团队已经遵循的任何 API 惯例

### 2. 按决策树逐步判断
使用 `templates/decision-tree.md`。先确定类别(2xx、3xx、4xx、5xx),再确定具体状态码。故障责任方决定使用 4xx 还是 5xx。

### 3. 运行辅助脚本(可选)
```
python3 scripts/suggest_status.py --method POST "created new order"
python3 scripts/suggest_status.py --method GET "user not logged in"
python3 scripts/suggest_status.py --json "email already exists"
```
将脚本结果视为初步猜测,并对照速查表确认。

### 4. 检查错误
将选择与 `references/common-mistakes.md` 比较,尤其检查 401 与 403、400 与 422,以及空 200 列表时使用 404 的情况。

### 5. 作答
返回:
1. **推荐状态码**和原因短语(例如 `409 Conflict`)
2. **理由**:用一到两句话结合实际情况说明
3. **考虑过的备选项**,以及它们不如推荐项的原因
4. 相关的**响应头**(`Location` 用于 201,`Retry-After` 用于 429/503,`WWW-Authenticate` 用于 401,`Allow` 用于 405)
5. 对于 4xx/5xx,使用 RFC 9457 Problem Details 格式编写**错误响应体草案**(`type`、`title`、`status`、`detail`、`instance`)

## 规则

- 操作失败时,绝不返回 2xx。
- 只有服务器或其依赖的上游发生故障时才使用 5xx,绝不用于客户端输入错误。
- 如果团队现有惯例合理,就保持一致;若其与标准选择不同,只需说明一次标准选择。
- 如果出于安全原因必须隐藏资源是否存在,在建议使用 404 而非 403 时明确说明。
FILE:references/common-mistakes.md
# 常见状态码错误

1. **带错误响应体的 200**(`{"success": false}`)。客户端、代理、缓存和监控系统都会将 200 视为成功。应使用正确的 4xx/5xx 状态码,并将详细信息放入响应体。

2. **混淆 401 和 403。**401 表示“你是谁?”(凭据缺失或无效)。403 表示“我知道你是谁,但答案是不允许。”已登录但无权限的用户应收到 403,而不是 401。

3. **输入错误时使用 500。**验证失败应由客户端负责:使用 400 或 422。500 应留给程序缺陷和意外的服务器故障。500s 激增应意味着你这边出了问题。

4. **空集合时使用 404。**没有匹配项的 `GET /users?role=admin` 应返回带有 `[]` 的 `200`。404 用于资源不存在的情况,例如 `/users/9999`。

5. **所有情况都使用 400。**如果更具体的状态码能帮助客户端采取行动,就优先使用它:重复项用 409,内容过大用 413,内容类型错误用 415,超出速率限制用 429。

6. **使用 201 却不带 `Location`。**创建了带有独立 URL 的资源时,应发送 `Location: /orders/123`。

7. **204 带有响应体。**204 不得包含内容。如果要返回资源,请使用 200。

8. **在 API 中使用 302。**客户端可能会将 POST 改为 GET。方法必须保持不变时,请使用 307 或 308。

9. **混淆 503 和 500。**503 表示临时故障且可以重试(维护、过载),最好附上 `Retry-After`。500 表示意外错误。

10. **通过 403 泄露资源是否存在。**对于私有资源,向未授权用户返回 404 可以防止枚举。请审慎决定并记录这一做法。

11. **不一致地使用 422 和 400。**选定一种规则(400 = 无法解析,422 = 已解析但无效),并应用于每个端点。

12. **使用非标准状态码**(例如在生产环境中使用 418、499、自定义 6xx)。许多客户端和代理无法妥善处理它们。请使用已注册的状态码。
FILE:references/status-cheatsheet.md
# API 的 HTTP 状态码速查表

依据 RFC 9110(HTTP 语义)、RFC 6585(429)和 RFC 9457(Problem Details)。

## 2xx 成功
| 状态码 | 名称 | 适用场景 |
|---|---|---|
| 200 | OK(成功) | 请求成功,响应体包含结果(GET,或返回更新后资源的 PUT/PATCH)。 |
| 201 | Created(已创建) | 创建了新资源。发送带有资源 URL 的 `Location`;响应体通常包含该资源。 |
| 202 | Accepted(已接受) | 任务已加入队列,稍后完成。返回状态 URL 或任务 ID。 |
| 204 | No Content(无内容) | 操作成功且没有响应体(DELETE,或不返回内容的 PUT/PATCH)。 |
| 206 | Partial Content(部分内容) | 根据 `Range` 请求头提供字节范围内容。 |

## 3xx 重定向
| 状态码 | 名称 | 适用场景 |
|---|---|---|
| 301 | Moved Permanently(永久移动) | 资源有了新的永久 URL(旧客户端可能会将方法改为 GET)。 |
| 304 | Not Modified(未修改) | 条件 GET(`If-None-Match` / `If-Modified-Since`),且资源未发生变化。 |
| 307 | Temporary Redirect(临时重定向) | 临时移动;客户端必须使用相同的方法和响应体重新发送请求。 |
| 308 | Permanent Redirect(永久重定向) | 永久移动;保持相同的方法和响应体。 |

## 4xx 客户端错误
| 状态码 | 名称 | 适用场景 |
|---|---|---|
| 400 | Bad Request(错误请求) | 语法格式错误:JSON 无效、内容结构错误、缺少必需的查询参数。 |
| 401 | Unauthorized(未授权) | 没有凭据,或凭据无效/已过期。发送 `WWW-Authenticate`。 |
| 403 | Forbidden(禁止访问) | 已通过身份验证,但不允许执行此操作。 |
| 404 | Not Found(未找到) | 资源不存在(或需要隐藏其存在)。 |
| 405 | Method Not Allowed(方法不允许) | URL 存在,但不支持此方法。发送 `Allow`。 |
| 406 | Not Acceptable(不可接受) | 无法生成 `Accept` 中列出的任何格式。 |
| 409 | Conflict(冲突) | 请求与当前状态冲突:唯一键重复、版本冲突、状态转换无效。 |
| 410 | Gone(资源已删除) | 资源曾经存在,但已被永久删除。 |
| 412 | Precondition Failed(前置条件失败) | `If-Match` / `If-Unmodified-Since` 检查失败(乐观锁定)。 |
| 413 | Content Too Large(内容过大) | 响应体超过大小限制。 |
| 415 | Unsupported Media Type(不支持的媒体类型) | 不支持 `Content-Type`。 |
| 422 | Unprocessable Content(无法处理的内容) | 语法正确,但值未通过验证(邮箱无效、结束日期早于开始日期)。 |
| 428 | Precondition Required(需要前置条件) | 服务器要求使用条件请求,以防止更新丢失。 |
| 429 | Too Many Requests(请求过多) | 超出速率限制。发送 `Retry-After`。 |

## 5xx 服务器错误
| 状态码 | 名称 | 适用场景 |
|---|---|---|
| 500 | Internal Server Error(内部服务器错误) | 代码中发生意外故障。 |
| 501 | Not Implemented(尚未实现) | 服务器完全不支持此功能。 |
| 502 | Bad Gateway(网关错误) | 上游返回了无效响应。 |
| 503 | Service Unavailable(服务不可用) | 暂时过载或正在维护。若已知,请发送 `Retry-After`。 |
| 504 | Gateway Timeout(网关超时) | 上游未能及时响应。 |
FILE:templates/decision-tree.md
# 状态码决策树

按顺序回答。遇到第一个符合的情况时停止。

1. **请求成功了吗?**
   - 是,处理已完成且创建了新资源 -> **201**(+ `Location`)
   - 是,已接受处理,但稍后才会完成 -> **202**(+ 状态 URL)
   - 是,没有内容需要返回 -> **204**
   - 是,返回了数据 -> **200**
   - 否 -> 转到 2

2. **客户端是否正在被重定向?**
   - 条件 GET,资源未改变 -> **304**
   - 资源已移动,必须保留请求方法 -> **307**(临时)/ **308**(永久)
   - 否 -> 转到 3

3. **这是身份验证问题吗?**
   - 没有凭据,或令牌无效/已过期 -> **401**(+ `WWW-Authenticate`)
   - 否 -> 转到 4

4. **已通过身份验证的调用者是否无权执行操作?**
   - 是,且可以透露资源是否存在 -> **403**
   - 是,但必须隐藏资源是否存在 -> **404**
   - 否 -> 转到 5

5. **目标资源不存在,或请求方法错误吗?**
   - 资源不存在 -> **404**
   - 资源已被永久删除 -> **410**
   - URL 存在,但不支持此方法 -> **405**(+ `Allow`)
   - 否 -> 转到 6

6. **请求本身无法使用吗?**
   - 无法解析(JSON 错误、类型错误)-> **400**
   - `Content-Type` 错误 -> **415**
   - 响应体过大 -> **413**
   - 无法满足 `Accept` -> **406**
   - 否 -> 转到 7

7. **输入有效,但被拒绝了吗?**
   - 未通过业务验证 -> **422**
   - 与当前状态冲突(重复项、版本、状态机)-> **409**
   - `If-Match` 前置条件检查失败 -> **412**
   - 请求过多 -> **429**(+ `Retry-After`)
   - 否 -> 转到 8

8. **服务器端故障**
   - 上游返回了无效内容 -> **502**
   - 上游超时 -> **504**
   - 暂时不可用/正在维护 -> **503**(+ `Retry-After`)
   - 完全不支持此功能 -> **501**
   - 意外的程序缺陷 -> **500**

## 回答模板

```
Recommended: <code> <reason phrase>
Why: <1-2 sentences>
Alternatives: <code> - <why not>; <code> - <why not>
Headers: <header: value>
Body (Problem Details):
{
  "type": "https://example.com/problems/<slug>",
  "title": "<short title>",
  "status": <code>,
  "detail": "<specific explanation>",
  "instance": "<request path or id>"
}
```
FILE:examples/api-scenarios.md
# API 场景示例

## 1. 使用已注册的邮箱注册
使用 `{"email": "ana@example.com"}` 的 `POST /users`,且该邮箱已被占用。
- **推荐:**409 Conflict(冲突)
- **理由:**输入格式正确且有效,但与已有状态(唯一键)冲突。
- **备选项:**422(如果团队对所有规则失败都使用 422,也可以接受,但 409 能让客户端知道具体问题是状态冲突);400(过于笼统)。
- **响应体:**
```json
{"type": "https://example.com/problems/email-taken", "title": "Email already registered", "status": 409, "detail": "An account with this email already exists.", "instance": "/users"}
```

## 2. 创建订单
`POST /orders` 成功并返回订单。
- **推荐:**201 Created(已创建),`Location: /orders/8812`
- **备选项:**200(失去了“已创建”这一信号),202(仅当履约为异步处理且订单尚未持久化时使用)。

## 3. 报告导出任务已排队
`POST /reports/exports` 启动一个 5 分钟的任务。
- **推荐:**202 Accepted(已接受),并带有 `{"jobId": "exp_41", "statusUrl": "/reports/exports/exp_41"}`

## 4. 查看者尝试删除项目
具有只读角色的已登录用户调用 `DELETE /projects/7`。
- **推荐:**403 Forbidden(禁止访问)
- **理由:**用户身份已知,但缺少权限。401 会错误地暗示用户应重新登录。

## 5. 访问令牌已过期
`GET /me` 使用了已过期的 JWT。
- **推荐:**401 Unauthorized(未授权),`WWW-Authenticate: Bearer error="invalid_token"`

## 6. 搜索没有匹配项
`GET /products?q=zzzz`
- **推荐:**200 OK(成功),响应体为 `{"items": [], "total": 0}`
- **理由:**集合存在,只是内容为空。404 会导致将其视为错误的客户端出现问题。

## 7. 开始日期晚于结束日期
`POST /bookings` 使用了 `start=2026-10-20`,`end=2026-10-18`。
- **推荐:**422 Unprocessable Content(无法处理的内容)
- **备选项:**400(如果团队惯例将解析错误和验证错误合并处理,也可以接受)。

## 8. 支付服务提供商超时
`POST /checkout`,且支付网关在 10 秒内未作出响应。
- **推荐:**504 Gateway Timeout(网关超时)
- **备选项:**502(如果网关返回了无效响应),503(如果你有意削减负载)。

## 9. 登录尝试次数过多
- **推荐:**429 Too Many Requests(请求过多),`Retry-After: 60`

## 10. 过期版本更新
`PUT /docs/3` 使用了 `If-Match: "v4"`,但当前版本是 `"v5"`。
- **推荐:**412 Precondition Failed(前置条件失败)
FILE:scripts/suggest_status.py
#!/usr/bin/env python3
"""根据简短的场景描述推荐 HTTP 状态码。

用法:
    python3 suggest_status.py [--method METHOD] [--json] "场景文本"
    python3 suggest_status.py --self-test

基于关键词,刻意保持简单:提供初步猜测和备选项。请始终对照 references/status-cheatsheet.md 确认。
仅使用 Python 3 标准库。
"""
import argparse
import json
import re
import sys

# 有序规则:第一个匹配项生效。每条规则:(关键词、状态码、理由、备选项、响应头)
RULES = [
    (["rate limit", "too many", "throttl", "quota exceeded"], 429,
     "客户端超出速率限制或配额。", [503], ["Retry-After"]),
    (["未登录", "没有令牌", "缺少令牌", "令牌已过期", "无效令牌",
      "未通过身份验证", "没有凭据", "凭据错误", "需要登录"], 401,
     "凭据缺失或无效。", [403], ["WWW-Authenticate"]),
    (["没有权限", "不允许", "禁止访问", "角色权限不足", "未获授权",
      "只读用户", "不是所有者"], 403,
     "调用者已通过身份验证,但缺少权限。", [404], []),
    (["已存在", "重复", "冲突", "版本不匹配", "已被占用",
      "状态无效", "状态转换"], 409,
     "请求与资源的当前状态冲突。", [422, 412], []),
    (["precondition", "if-match", "etag mismatch", "stale"], 412,
     "条件请求头不匹配。", [409], ["ETag"]),
    (["too large", "payload size", "file too big", "body too big"], 413,
     "请求响应体超出允许的大小。", [400], []),
    (["unsupported media", "content-type", "wrong content type"], 415,
     "不支持此 Content-Type。", [400], ["Accept"]),
    (["格式错误", "JSON 无效", "解析错误", "无法解析", "语法错误",
      "缺少必需内容"], 400,
     "请求无法解析或缺少必需部分。", [422], []),
    (["验证", "邮箱无效", "超出范围", "必须", "早于开始日期",
      "值无效", "未通过规则"], 422,
     "请求格式正确,但值未通过验证。", [400], []),
    (["method not allowed", "wrong method", "unsupported method"], 405,
     "URL 存在,但不支持此方法。", [404], ["Allow"]),
    (["gone", "permanently deleted", "removed permanently"], 410,
     "资源曾经存在,但已被永久删除。", [404], []),
    (["not found", "does not exist", "no such", "unknown id"], 404,
     "目标资源不存在。", [410], []),
    (["upstream timeout", "gateway timeout", "timed out", "timeout"], 504,
     "上游依赖未能及时响应。", [502, 503], []),
    (["bad gateway", "upstream error", "invalid upstream", "upstream returned"], 502,
     "上游依赖返回了无效响应。", [504], []),
    (["maintenance", "overloaded", "unavailable", "down for"], 503,
     "服务暂时不可用。", [500], ["Retry-After"]),
    (["not implemented", "unsupported feature"], 501,
     "服务器不支持此功能。", [405], []),
    (["crash", "exception", "bug", "unexpected error", "internal error"], 500,
     "服务器端发生意外故障。", [503], []),
    (["queued", "async", "background job", "accepted for processing", "will process"], 202,
     "任务已接受,将在稍后完成。", [201], ["Location"]),
    (["created", "new resource", "signed up", "registered"], 201,
     "创建了新资源。", [200, 202], ["Location"]),
    (["deleted", "no content", "nothing to return"], 204,
     "操作成功,且没有响应体。", [200], []),
    (["not modified", "unchanged", "cache hit"], 304,
     "条件请求中的资源未发生变化。", [200], ["ETag"]),
    (["empty list", "no results", "no matches", "empty collection"], 200,
     "集合存在但为空:返回带有空列表的 200。", [404], []),
    (["success", "ok", "returned", "fetched", "updated"], 200,
     "请求成功并返回内容。", [204], []),
]

REASONS = {
    200: "成功", 201: "已创建", 202: "已接受", 204: "无内容", 304: "未修改",
    400: "错误请求", 401: "未授权", 403: "禁止访问", 404: "未找到",
    405: "方法不允许", 409: "冲突", 410: "资源已删除", 412: "前置条件失败",
    413: "内容过大", 415: "不支持的媒体类型", 422: "无法处理的内容",
    429: "请求过多", 500: "内部服务器错误", 501: "尚未实现",
    502: "网关错误", 503: "服务不可用", 504: "网关超时",
}


def suggest(text, method=None):
    t = " " + re.sub(r"\s+", " ", text.lower()) + " "
    for keywords, code, reason, alts, headers in RULES:
        for kw in keywords:
            # 对于 "ok" 这类很短的关键词,使用词边界匹配
            if len(kw) <= 3:
                if re.search(r"\b" + re.escape(kw) + r"\b", t):
                    break
            elif kw in t:
                break
        else:
            continue
        m = (method or "").upper()
        notes = []
        if code == 200 and m == "DELETE":
            code, reason, alts = 204, "DELETE 操作成功;通常不返回响应体。", [200]
        if code == 201 and m and m not in ("POST", "PUT"):
            notes.append("201 通常由 POST 或 PUT 返回。")
        if code == 204 and m == "GET":
            notes.append("GET 很少返回 204;优先选择带响应体的 200。")
        return {
            "code": code,
            "reason_phrase": REASONS.get(code, ""),
            "why": reason,
            "alternatives": [f"{a} {REASONS.get(a, '')}".strip() for a in alts],
            "headers": headers,
            "matched_keyword": kw,
            "notes": notes,
        }
    return {
        "code": None,
        "reason_phrase": "",
        "why": "没有匹配的规则。请手动按照 templates/decision-tree.md 逐步判断。",
        "alternatives": [],
        "headers": [],
        "matched_keyword": None,
        "notes": [],
    }


def self_test():
    cases = [
        ("创建新订单", "POST", 201),
        ("用户未登录", "GET", 401),
        ("只读用户尝试删除", "DELETE", 403),
        ("邮箱已存在", "POST", 409),
        ("搜索无结果", "GET", 200),
        ("结束日期早于开始日期验证", "POST", 422),
        ("JSON 请求体无效", "POST", 400),
        ("支付网关超时", "POST", 504),
        ("登录尝试次数过多", "POST", 429),
        ("订单 ID 不存在", "GET", 404),
        ("报告导出任务已作为后台任务排队", "POST", 202),
        ("记录已删除", "DELETE", 204),
        ("处理程序中出现意外异常", "GET", 500),
        ("发生了异常情况", "GET", None),
    ]
    failed = 0
    for text, method, expected in cases:
        got = suggest(text, method)["code"]
        ok = got == expected
        failed += not ok
        print(f"{'PASS' if ok else 'FAIL'}  {method:6} {text!r} -> {got} (期望为 {expected})")
    print(f"\n{len(cases) - failed}/{len(cases)} 项通过")
    return 1 if failed else 0


def main(argv=None):
    p = argparse.ArgumentParser(description="根据 API 场景推荐 HTTP 状态码。")
    p.add_argument("scenario", nargs="*", help="对所发生情况的简短描述")
    p.add_argument("--method", help="HTTP 方法,例如 POST")
    p.add_argument("--json", action="store_true", help="打印 JSON 输出")
    p.add_argument("--self-test", action="store_true", help="运行内置测试")
    a = p.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.scenario:
        p.error("必须提供场景文本(或使用 --self-test)")
    r = suggest(" ".join(a.scenario), a.method)
    if a.json:
        print(json.dumps(r, indent=2))
        return 0
    if r["code"] is None:
        print(r["why"])
        return 2
    print(f"推荐:{r['code']} {r['reason_phrase']}")
    print(f"理由:{r['why']}")
    if r["alternatives"]:
        print("备选项:" + ", ".join(r["alternatives"]))
    if r["headers"]:
        print("响应头:" + ", ".join(r["headers"]))
    for n in r["notes"]:
        print("备注:" + n)
    return 0


if __name__ == "__main__":
    sys.exit(main())

使用建议

使用建议:将接口方法、路径和实际发生的情况一起提供给对话模型;需要批量获得初步建议时,再在桌面环境运行随附的 Python 辅助脚本。

信息

类型
文本
模型
通用对话模型
来源
f
许可
CC0-1.0

反馈这条内容