跳到主要内容

错误与恢复

程序按 HTTP 状态码和 error 分支。可选的 message 是写给日志和人看的,别拿它做 逻辑判断。

先看响应长什么样

请求还没进到命令处理,错误通常就这么简单:

{
"error": "UNAUTHORIZED"
}

一旦请求进了命令 gate,拒绝响应会多带一个 "accepted": false

{
"accepted": false,
"error": "TICK_MISMATCH",
"tick": 10582,
"current_tick": 10583
}

而计划本身不合法时,还会附上一条或多条原因:

{
"accepted": false,
"error": "INVALID_COMMAND",
"details": [
{
"unit_id": "9d3e4941-2816-4a39-a220-df8cd95e877d",
"reason": "RANGER_CANNOT_HARVEST"
},
{
"reason": "INVALID_UNIT_TYPE"
}
]
}

传输、身份验证、JSON、并发和服务端内部错误压根没有 accepted 字段——而它不出现, 从来不代表命令被接受了。

HTTP 错误

状态error额外字段出了什么问题
400INVALID_JSONmessage请求体为空或格式错误,含多个 JSON 值、未知字段、错误字段类型、格式错误的 UUID,或 unit_actions 使用了非标准 UUID key。
400IDEMPOTENCY_KEY_INVALID请求头缺失,或不是 8-128 个可见 ASCII 字节(0x21-0x7e)。
401UNAUTHORIZEDBearer 凭据缺失、无效或已停用。
403CSRF_INVALID浏览器 Manual 请求未通过 CSRF 校验。Agent Bearer 请求不使用 CSRF。
409COMMAND_WINDOW_CLOSEDaccepted: falseTick 存在,但命令窗口已关闭,或请求体在截止时间到达或之后才收完。
409TICK_MISMATCHaccepted: false;查到持久化记录后的响应还会带 tickcurrent_tick提交的 Tick 不是当前命令 Tick。
409IDEMPOTENCY_CONFLICTaccepted: false该玩家和来源已用同一 key 提交过不同的原始请求字节。
413REQUEST_BODY_TOO_LARGEmessage请求体超过当前部署的上限。
415UNSUPPORTED_MEDIA_TYPE解析后的媒体类型不是 application/json。允许 charset=utf-8 等参数。
422INVALID_COMMANDaccepted: false,非空 detailsJSON 结构正确,但玩家、Unit 或动作不符合规则。
429COMMAND_CONCURRENCY_LIMITRetry-After: 1 请求头同一玩家和凭据类型正在处理超过四个命令请求体。
429COMMAND_RATE_LIMITEDaccepted: falseRetry-After: 1 请求头(player, Tick, source) 已尝试超过 64 个新请求。
500INTERNAL_ERROR服务端没能完成请求。
503TICK_NOT_READYaccepted: falseTick 尚未初始化、玩家状态未准备好,或 Tick 处理失败。

请求体上限是部署配置,不属于协议。计划尽量小一点,那些只会表达 WAIT 的动作直接 别写。

该不该重试

结果是否用相同 key 和请求体重试下一步
上传后网络超时或连接重置在确认原结果前保持请求体不变。
500 INTERNAL_ERROR使用有上限的退避。
429 COMMAND_CONCURRENCY_LIMIT等待 Retry-After
503 TICK_NOT_READY通常先不要等待 state 或重连。收到更新状态后重新计算。
409 COMMAND_WINDOW_CLOSED仅用于找回可能已完成的原请求等下一份状态再制定新计划。
409 TICK_MISMATCH仅用于找回原幂等结果根据当前状态重新计算。
409 IDEMPOTENCY_CONFLICT只有真正的新请求才使用新 key。
422 INVALID_COMMAND修正计划。窗口仍开放时,用新 key 作为新请求提交。
429 COMMAND_RATE_LIMITED该来源和 Tick 不再提交新请求保留最后一份有效计划,等待下一份状态。
400401403413415不能原样重试先修正请求或凭据。

已完成的幂等响应会保留七天。在这七天里,同一个 key 配上逐字节相同的请求体,还是会 返回当初那个状态码和响应体,哪怕命令窗口早就关了。重放一个旧的 202 不会再存一次 计划,也不会再发一条 received

校验原因

问题出在某个 Unit 动作上时,details[].unit_id 会点名是哪个 Unit;问题出在整份计划 或 Core 动作上时,这个字段就不出现。

顺序是固定的:先 Tick 的问题,再按 UUID 字节序排的 Unit 问题,最后是 Core 的问题。

reason适用于怎么修
TICK_MUST_BE_POSITIVE计划tick 缺失、为零或为负数。
UNIT_NOT_OWNEDUnitKey 不是该玩家拥有的存活 Unit。
UNKNOWN_ACTION_TYPEUnittype 不是 Unit 动作。
UNKNOWN_CORE_ACTION_TYPECoretype 不是 Core 动作。
UNEXPECTED_ACTION_FIELDSUnit 或 Core动作带了该 type 不允许的字段,即使值是 null、空值或零。
INVALID_DIRECTIONMOVESWEEPSTART_MOVEdirection 缺失,或不是 UPDOWNLEFTRIGHT
INVALID_UNIT_TYPESPAWNunit_type 缺失,或不是 WORKERVANGUARDRANGER
TARGET_ID_REQUIREDSHOOTtarget_id 是零 UUID。格式错误的 UUID 会返回 INVALID_JSON
EXPECTED_CELL_REQUIREDSHOOTexpected_cell 缺失。
VANGUARD_CANNOT_HARVESTUnitVanguard 选择了 HARVEST
RANGER_CANNOT_HARVESTUnitRanger 选择了 HARVEST
VANGUARD_CANNOT_DEPOSITUnitVanguard 选择了 DEPOSIT
RANGER_CANNOT_DEPOSITUnitRanger 选择了 DEPOSIT
WORKER_CANNOT_SWEEPUnitWorker 选择了 SWEEP
RANGER_CANNOT_SWEEPUnitRanger 选择了 SWEEP
WORKER_CANNOT_SHOOTUnitWorker 选择了 SHOOT
VANGUARD_CANNOT_SHOOTUnitVanguard 选择了 SHOOT

INVALID_COMMAND 不会动你最后那份有效计划。

请求在哪一步停下

一个新请求会按这个顺序过检查:

  1. Bearer 身份验证,以及浏览器 Manual 请求的 CSRF;
  2. 每个玩家和凭据类型的请求体并发限制;
  3. Content-TypeIdempotency-Key
  4. 请求体大小和 JSON 解码;
  5. Tick、命令窗口和频率限制;
  6. 当前玩家、Unit 和动作字段;
  7. 幂等存储和计划替换。

知道了这个顺序,那些看着差不多的错误就分得清了。格式错误的 UUID 过不了第 4 步, 所以是 INVALID_JSON;而一个格式正确、但属于别人的 UUID 能活到第 6 步,于是返回的 是 INVALID_COMMANDUNIT_NOT_OWNED

WebSocket 错误

握手阶段可能返回:

  • 401 UNAUTHORIZED
  • 403 WEBSOCKET_ORIGIN_INVALID
  • 409 PLAYER_NOT_READY
  • 429 REALTIME_CONNECTION_LIMIT,并带 Retry-After: 1

升级成功之后,看 WebSocket 协议里的关闭码表。临时故障 用带随机抖动的指数退避,从 250 ms 涨到 5 秒;收到 1008 就停下来,先把凭据或客户端 行为修好。

重连之后,用服务端发来的快照替换本地状态和已存的回执。不要自己发明心跳消息。也不要 把 SHOT_MISSED 当成探测隐藏目标的手段——它就是一次「不知道为什么没打中」。