fix: stabilize puzzle vector engine asset generation

This commit is contained in:
2026-06-03 02:40:07 +08:00
parent 67ba40c678
commit 08577b66c5
6 changed files with 215 additions and 28 deletions

View File

@@ -25,9 +25,10 @@
## VectorEngine 图片生成 SendRequest 超时要按传输失败排查 ## VectorEngine 图片生成 SendRequest 超时要按传输失败排查
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send``timeout=true``statusCode=null``errorSource=client error (SendRequest)`,前端只知道图片生成失败。 - 现象:`external_api_call_failure` 里看到 `failureStage=request_send``timeout=true``statusCode=null``errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。 - 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id``metadata_json.userId/profileId` 定位触发者草稿 / 作品;`request_send + timeout=true` 优先查请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502``429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。 - 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id``metadata_json.userId/profileId/requestId` 定位触发者草稿 / 作品和同一次 HTTP 请求`request_send + timeout=true` 优先查 provider 日志的 `source_chain`请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502``429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
- 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot``asset_kind``elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id` - 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
@@ -365,7 +366,7 @@
- 现象VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片还是下载图片失败。 - 现象VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片还是下载图片失败。
- 原因:外部 API 失败如果只靠普通日志,不一定能和 OTLP 指标、trace 与 SpacetimeDB 历史查询稳定关联;重启后也容易丢失上下文。 - 原因:外部 API 失败如果只靠普通日志,不一定能和 OTLP 指标、trace 与 SpacetimeDB 历史查询稳定关联;重启后也容易丢失上下文。
- 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event``event_key = 'external_api_call_failure'``metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt。 - 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event``event_key = 'external_api_call_failure'``metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt 和 requestId
- 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。 - 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
@@ -778,8 +779,8 @@
- 现象RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败创建图片生成任务失败error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。 - 现象RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败创建图片生成任务失败error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。
- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401不能据此判断真实生图不会超时。 - 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401不能据此判断真实生图不会超时。
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `request_body_bytes`、每张参考图 Data URL 长度、`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`前端优先展示 `details.reason`。修改 `.env.secrets.local` 后必须重启 `api-server``npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。 - 处理:开局 CG 参考图入参先压到单边 768 的 JPEG`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `timeout/connect/body/source/source_chain/source_chain_depth/endpoint` 分类写入日志和 `error.details`失败审计通过 `metadata_json.errorSource/requestId` 保留底层错误链和请求标识。修改 `.env.secrets.local` 后必须重启 `api-server``npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG若仍失败看返回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint``logs/api-server/` 同一 request_id。 - 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG若仍失败看返回的 `details.errorSource/source/timeout/connect/body/endpoint``tracking_event.metadata_json.errorSource/requestId``logs/api-server/` 同一 request_id。
- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs``server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联:`server-rs/crates/api-server/src/custom_world_ai.rs``server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 开局 CG 成功后又变空白要保留 profile.openingCg ## 开局 CG 成功后又变空白要保留 profile.openingCg

View File

@@ -67,7 +67,7 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.3.0`。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.3.0`。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板`send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes``reference_data_url_bytes``sourceChain``rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1 本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id`provider 日志字段 `source``source_chain``source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot``asset_kind``elapsed_ms`
查看本地 Rust / SpacetimeDB 日志: 查看本地 Rust / SpacetimeDB 日志:
@@ -292,7 +292,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs但本地日
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
- api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id``metadata_json.userId` / `metadata_json.profileId` 中记录触发者草稿 / 作品作用域。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId最后结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 - 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id``metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- SpacetimeDB 观测分为两类procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*``read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 - SpacetimeDB 观测分为两类procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*``read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 - 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes请求完成日志会直接带 `request_id``http.request.method``http.route``url.scheme``url.path``http.response.status_code``status_class``latency_ms``slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 - Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes请求完成日志会直接带 `request_id``http.request.method``http.route``url.scheme``url.path``http.response.status_code``status_class``latency_ms``slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
@@ -351,7 +351,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start` 个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userIdprofileId其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,入口拿不到上下文时允许为空。常用查询: 外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userIdprofileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
```sql ```sql
SELECT event_id, scope_id AS provider, metadata_json, occurred_at SELECT event_id, scope_id AS provider, metadata_json, occurred_at
@@ -378,7 +378,7 @@ ORDER BY failures DESC, last_seen DESC
LIMIT 100; LIMIT 100;
``` ```
VectorEngine `request_send``timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource = client error (SendRequest)` Hyper 发送请求阶段的错误来源标签,不等于最终根因。若 `statusCode` 为空,应优先查同一时间窗口`api-server` request 日志、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502``429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。 VectorEngine `request_send``timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` `api-server` request 日志、provider 日志 `source_chain`Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502``429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
tracking outbox 默认配置: tracking outbox 默认配置:

View File

@@ -341,6 +341,8 @@ fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
prompt_chars = failure.prompt_chars, prompt_chars = failure.prompt_chars,
reference_image_count = failure.reference_image_count, reference_image_count = failure.reference_image_count,
image_model = failure.image_model, image_model = failure.image_model,
request_id = %failure.request_id.as_deref().unwrap_or_default(),
error_source = %failure.error_source.as_deref().unwrap_or_default(),
error = %failure.error_message, error = %failure.error_message,
"外部 API 调用失败" "外部 API 调用失败"
); );
@@ -394,6 +396,10 @@ mod tests {
) )
.with_status_code(Some(429)) .with_status_code(Some(429))
.with_retryable(true) .with_retryable(true)
.with_error_source(Some(
"client error (SendRequest) -> connection closed before message completed"
.to_string(),
))
.with_latency_ms(Some(1234)) .with_latency_ms(Some(1234))
.with_prompt_chars(Some(88)) .with_prompt_chars(Some(88))
.with_reference_image_count(Some(2)) .with_reference_image_count(Some(2))
@@ -414,6 +420,10 @@ mod tests {
assert_eq!(metadata["promptChars"], 88); assert_eq!(metadata["promptChars"], 88);
assert_eq!(metadata["referenceImageCount"], 2); assert_eq!(metadata["referenceImageCount"], 2);
assert_eq!(metadata["imageModel"], "gpt-image-2-all"); assert_eq!(metadata["imageModel"], "gpt-image-2-all");
assert_eq!(
metadata["errorSource"],
"client error (SendRequest) -> connection closed before message completed"
);
assert!(matches!(metadata["occurredAt"], Value::String(_))); assert!(matches!(metadata["occurredAt"], Value::String(_)));
} }

View File

@@ -424,6 +424,7 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
details["referenceImageCount"] = json!(audit.reference_image_count); details["referenceImageCount"] = json!(audit.reference_image_count);
details["imageModel"] = json!(audit.image_model); details["imageModel"] = json!(audit.image_model);
details["rawExcerpt"] = json!(audit.raw_excerpt); details["rawExcerpt"] = json!(audit.raw_excerpt);
details["errorSource"] = json!(audit.error_source);
} }
AppError::from_status(status).with_details(details) AppError::from_status(status).with_details(details)

View File

@@ -317,7 +317,16 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
); );
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?; let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image); let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let scene_generated = create_puzzle_vector_engine_image_generation( let bundle_started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
"拼图关卡资产包生成开始"
);
let scene_started_at = Instant::now();
let scene_generated = match create_puzzle_vector_engine_image_generation(
&http_client, &http_client,
&settings, &settings,
PuzzleImageModel::GptImage2, PuzzleImageModel::GptImage2,
@@ -328,7 +337,34 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
Some(&puzzle_reference), Some(&puzzle_reference),
) )
.await .await
.map_err(map_puzzle_generation_endpoint_error)?; .map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图生成完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡场景图生成失败"
);
return Err(error);
}
};
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| { let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER, "provider": VECTOR_ENGINE_PROVIDER,
@@ -336,7 +372,8 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
})) }))
})?; })?;
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image); let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
let scene_persist_future = persist_puzzle_level_asset_image( let scene_persist_started_at = Instant::now();
let level_scene = persist_puzzle_level_asset_image(
state, state,
owner_user_id, owner_user_id,
session_id, session_id,
@@ -347,8 +384,18 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"level_scene", "level_scene",
"scene", "scene",
scene_image, scene_image,
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_persist_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图持久化完成"
); );
let spritesheet_future = generate_and_persist_puzzle_level_asset( let ui_spritesheet = generate_and_persist_puzzle_level_asset(
state, state,
&http_client, &http_client,
&settings, &settings,
@@ -362,8 +409,9 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"puzzle_ui_spritesheet_image", "puzzle_ui_spritesheet_image",
"ui_spritesheet", "ui_spritesheet",
"spritesheet", "spritesheet",
); )
let background_future = generate_and_persist_puzzle_level_asset( .await?;
let level_background = generate_and_persist_puzzle_level_asset(
state, state,
&http_client, &http_client,
&settings, &settings,
@@ -377,14 +425,21 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"puzzle_level_background_image", "puzzle_level_background_image",
"level_background", "level_background",
"background", "background",
); )
let (level_scene, ui_spritesheet, level_background) = .await?;
tokio::join!(scene_persist_future, spritesheet_future, background_future);
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
elapsed_ms = bundle_started_at.elapsed().as_millis() as u64,
"拼图关卡资产包生成完成"
);
Ok(GeneratedPuzzleLevelAssetBundle { Ok(GeneratedPuzzleLevelAssetBundle {
level_scene: level_scene?, level_scene,
ui_spritesheet: ui_spritesheet?, ui_spritesheet,
level_background: level_background?, level_background,
}) })
} }
@@ -403,7 +458,20 @@ async fn generate_and_persist_puzzle_level_asset(
slot: &str, slot: &str,
file_stem: &str, file_stem: &str,
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> { ) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
let generated = create_puzzle_vector_engine_image_generation( let started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
size,
prompt_chars = prompt.chars().count(),
reference_image_bytes = reference_image.bytes_len,
"拼图关卡资产生成请求开始"
);
let generated = match create_puzzle_vector_engine_image_generation(
http_client, http_client,
settings, settings,
PuzzleImageModel::GptImage2, PuzzleImageModel::GptImage2,
@@ -414,7 +482,36 @@ async fn generate_and_persist_puzzle_level_asset(
Some(reference_image), Some(reference_image),
) )
.await .await
.map_err(map_puzzle_generation_endpoint_error)?; .map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"拼图关卡资产生成请求完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡资产生成请求失败"
);
return Err(error);
}
};
let image = generated.images.into_iter().next().ok_or_else(|| { let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER, "provider": VECTOR_ENGINE_PROVIDER,
@@ -427,7 +524,8 @@ async fn generate_and_persist_puzzle_level_asset(
image image
}; };
persist_puzzle_level_asset_image( let persist_started_at = Instant::now();
let persisted = persist_puzzle_level_asset_image(
state, state,
owner_user_id, owner_user_id,
session_id, session_id,
@@ -439,7 +537,19 @@ async fn generate_and_persist_puzzle_level_asset(
file_stem, file_stem,
image, image,
) )
.await .await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图关卡资产持久化完成"
);
Ok(persisted)
} }
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent( pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(

View File

@@ -11,6 +11,7 @@ pub fn build_vector_engine_image_http_client(
reqwest::Client::builder() reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) .timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
.http1_only() .http1_only()
.pool_max_idle_per_host(0)
.build() .build()
.map_err(|error| PlatformImageError::InvalidConfig { .map_err(|error| PlatformImageError::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER, provider: VECTOR_ENGINE_PROVIDER,
@@ -29,7 +30,14 @@ pub(super) fn map_reqwest_error(
) -> PlatformImageError { ) -> PlatformImageError {
let is_timeout = error.is_timeout(); let is_timeout = error.is_timeout();
let is_connect = error.is_connect(); let is_connect = error.is_connect();
let source = error.source().map(ToString::to_string); let source_chain_parts = collect_error_source_chain(&error);
let source = source_chain_parts.first().cloned();
let source_chain_depth = source_chain_parts.len();
let source_chain = if source_chain_parts.is_empty() {
None
} else {
Some(source_chain_parts.join(" -> "))
};
let message = format!("{context}{error}"); let message = format!("{context}{error}");
let audit = build_failure_audit( let audit = build_failure_audit(
request_url, request_url,
@@ -40,7 +48,7 @@ pub(super) fn map_reqwest_error(
is_timeout, is_timeout,
is_connect, is_connect,
message.as_str(), message.as_str(),
source.clone(), source_chain.clone().or_else(|| source.clone()),
None, None,
Some(latency_ms), Some(latency_ms),
prompt_chars, prompt_chars,
@@ -56,6 +64,8 @@ pub(super) fn map_reqwest_error(
body = error.is_body(), body = error.is_body(),
status = error.status().map(|status| status.as_u16()).unwrap_or_default(), status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
source = %source.clone().unwrap_or_default(), source = %source.clone().unwrap_or_default(),
source_chain = %source_chain.clone().unwrap_or_default(),
source_chain_depth,
message = %message, message = %message,
elapsed_ms = latency_ms, elapsed_ms = latency_ms,
prompt_chars, prompt_chars,
@@ -72,7 +82,62 @@ pub(super) fn map_reqwest_error(
request: error.is_request(), request: error.is_request(),
body: error.is_body(), body: error.is_body(),
status_code: error.status().map(|status| status.as_u16()), status_code: error.status().map(|status| status.as_u16()),
source, source: source_chain.or(source),
audit: Some(audit), audit: Some(audit),
} }
} }
fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec<String> {
let mut chain = Vec::new();
let mut next = error.source();
while let Some(source) = next {
chain.push(source.to_string());
next = source.source();
}
chain
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt;
#[derive(Debug)]
struct TestError {
message: &'static str,
source: Option<Box<TestError>>,
}
impl fmt::Display for TestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message)
}
}
impl Error for TestError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn Error + 'static))
}
}
#[test]
fn collect_error_source_chain_keeps_nested_causes() {
let error = TestError {
message: "top",
source: Some(Box::new(TestError {
message: "middle",
source: Some(Box::new(TestError {
message: "bottom",
source: None,
})),
})),
};
assert_eq!(
collect_error_source_chain(&error),
vec!["middle".to_string(), "bottom".to_string()]
);
}
}