Java EE 企业应用 32:外部 HTTP 超时与未知结果
超时后,供应商可能已经收到了订单
通知消费者向供应商采购接口发送订单。只有能证明请求字节根本未发出、且没有代理代发时,才可断言这次请求未进入对端;连接异常的文字本身不足以证明这一点。写完请求后读取响应超时,更不能判断对端是否已经提交。把超时统一解释成“发送失败,立刻重试”会制造重复采购。同一个客户端异常,在外部系统可能对应未处理、已处理或处理中三种状态。
现有采购用例与存储端口只处理本地订单;演示 REST 资源是入站接口,不是出站供应商客户端。当前工程没有 supplier stub、出站 HTTP gateway、供应商请求日志或结果查询接口;不能将同步下单场景当成外部通知测试。
预算属于业务操作,不属于某次 socket
拟议出站接口 SupplierGateway.send(orderId, tenantId, idempotencyKey) 的幂等键从稳定业务任务 ID 派生,必须跨发布器重试、消费重投、进程重启保持不变。Supplier stub 对 (supplier_id, idempotency_key) 施加唯一约束并提供按键查询;真实供应商若没有该能力,消费者不能声称自动重试不会重复。记录 task_id、尝试号、请求摘要、HTTP 状态、是否收到完整响应及对端结果标识(如供应商订单 ID);不记认证头、完整个人资料。
Jakarta RESTful Web Services 4.0 的 Client API 提供 ClientBuilder、WebTarget、Invocation、Response。连接与读取超时须使用该 API 可用的配置方式或选定实现明确记录的属性;冻结客户端实现及数值,如连接 2 秒、读取 3 秒、总操作窗口 10 秒、最多 2 次尝试(均为教学预算,不是规范默认值)。Response 和 Client 使用后关闭;在受管环境下选择客户端生命周期时还须避免每条消息无限新建连接。超时并不等于供应商确认失败,响应 500 也不保证对端没有产生副作用。
尝试结果分为 CONFIRMED、REJECTED、UNKNOWN。完整、可关联的成功响应或可靠查询结果才进入 CONFIRMED;明确的业务拒绝进入 REJECTED。响应丢失则进入 UNKNOWN,优先按相同幂等键查询,再决定是否重试。UNKNOWN 达到预算后进入人工核对队列,而不是无限循环。数据库事务不能包住外部调用然后期望本地回滚撤销远端订单;不要在事务内长时间占用连接等待网络响应。
超时配置与一次业务操作的预算
Jakarta REST Client 的 ClientBuilder.connectTimeout 和 readTimeout 可分别限制建连和等待读取。它们不是整个采购动作的总时限:DNS 解析、排队、服务端处理、重试前退避、结果查询都要算进本地操作预算。教学参数约定建连 2 秒、读取 3 秒、两次发送尝试以及从第一步起最多 10 秒;如果剩余预算不足以容纳一次完整尝试,就先查询或结束为 UNKNOWN。真实配置不能仅把参数写进文章;要记录所用客户端实现版本、实际构造 Client 的入口、timeout 被设置的代码、故障注入后的耗时分布,以及代理/负载均衡器是否施加另一层超时。
客户端与 Response 的生命周期不能留给 GC 处理。长生命周期客户端可以被复用,但要保证部署停止时正确关闭;每次返回的 Response 无论 2xx、4xx、5xx 都要释放。在异常路径若拿不到 Response,同样记录请求是否已写出、是否已经读取到状态行,而不是“timeout”一个字符串。连续慢请求还可能耗尽客户端连接池、MDB 工作线程和受管数据库连接:发送期间不应持有订单写事务中的数据库连接。数据库状态 UNKNOWN 应在独立的、短事务中持久化,避免进程停止时仅内存里还有“正在查”的列表。
例子:任务 T 第一次连接失败;只有证据表明未发送请求,才可判断这次没有进入对端,下一次仍沿用同一幂等键。第二次对端已经提交却在回包前断开,客户端仍返回异常。此时按键查询返回关联该订单的供应商订单 ID,才能把 T 标为 CONFIRMED。如果查询也超时,T 保持 UNKNOWN 并等待后续对账,不能因为“已用完两次”就把它改成业务拒绝。重试预算控制请求次数和最迟停止时间,不决定对端的最终状态。
双方如何约定一个不会漂移的键
外部供应商合同至少要说明:订单创建入口、请求体版本、返回的供应商单号、按键查询的接口、键保留多久,以及同键换载荷会怎样。正常情况下,同一键多次请求返回同一供应商单;载荷不同则明确拒绝而不是覆盖旧单。键可以由稳定的 task_id 与供应商标识确定,并在本地用唯一约束绑定 order_id 与请求摘要。订单修改后如需二次通知,应生成新的业务事件和新键,不能借旧键请求不同内容。供应商若只实现“缓存一小时的请求 ID”,而 outbox 可能三天后才重试,两个生命周期不匹配,不能宣称跨重启幂等已经闭环。
目前没有供应商合同,只能把以上当作对教学 stub 的待实现验收要求。一个能固定“提交后断连”的 stub 必须先把 (supplier_id, idempotency_key, order_id, response_id) 写进自己的独立持久账本,再按故障开关关闭连接。若先断连接、后做业务,无法证明客户端面对真正的未知结果。慢响应应区分“请求到达后故意等待”和“根本连不上”;错误响应同时记录 stub 是否已执行副作用。为了演示强保证,stub 的状态查询也要从其自己的账本回答,不能从采购应用数据库里拼出“供应商结果”。
不同失败对应不同决定
当供应商返回明确、可关联的业务拒绝(例如已关闭的 SKU),可以保存拒绝原因并停止自动发送,但通用的 400、409 并非天然可归入这一类,须服从供应商接口契约。收到 429 或 503 时先考虑有限退避与 Retry-After,同时受本操作剩余预算限制;绝不能因为 HTTP 状态属于 5xx 就无上限重放。远端 2xx 还须验证业务键、订单 ID、返回格式及是否属于最终确认;对端如果返回 202 只表示接受排队,需要后续查询实际状态。响应畸形、只收了半个 JSON 或证书验证失败都应形成有原因码的记录,避免在日志里泄漏认证头。
客户端的幂等不能替代业务授权。出站网关应只接受已经核实的租户和供应商映射,不直接信任演示 HTTP 头 X-Lab-Tenant。对敏感出站地址还要限制允许的供应商域名、TLS 配置和凭证注入;隔离 stub 固定绑定本机,不能借故障注入对公网供应商制造真实订单。永久失败与结果未知的人工核对,需要与采购单及消息 task_id 关联,否则排查人员无法分辨是业务重复、消费者重投还是供应商账本延迟。
能复跑的现状检查
真实的资源方法只接受入站请求;数据库迁移目前没有供应商账本。在已部署的隔离环境运行采购脚本后,可用以下只读命令确认使用的模块与当前库的缺口;口令从环境注入,未部署时 psql 不成功是环境门槛失败:
1 | |
当前 rg 不匹配出站实现、最后返回 true 均只是说明采购应用尚未接入。JDK 21 独立供应商 stub 已建于 stubs/supplier,只绑定 127.0.0.1,支持慢响应屏障、422/503、提交后断连/500 和持久账本;它不依赖采购 WAR 或 PostgreSQL。bash examples/javaee-enterprise/stubs/supplier/selfcheck.sh 的两次自检均退出 0,其中本次原始输出见 writing-plans/javaee-enterprise/verification/20261005T-final-check/supplier-stub.stdout.txt:最终订单 6、有效请求 12;不同场景各有独立键,不能将“请求数 12”写成“同一任务发送 12 次”。供应商账本的 6 张订单是 stub 内部终态,不是采购系统订单。接下来仍须固定 Jakarta REST Client 实现与超时预算,并把稳定键、查询、断连恢复接入实际采购应用;当前没有 scenarios/32-supplier.sh,不能凭 stub 自检将出站集成标为通过。
实验:按供应商账本核对响应与断连
现有可执行基线仅核查采购订单(前提:JDK 21、Maven wrapper、独立 PostgreSQL、Open Liberty 26.0.0.5 和本地隔离 demo 配置,步骤见工程说明):
1 | |
采购应用尚无供应商出站集成验收;但 stub 的可控独立测试已运行。实测慢响应在服务端账本提交的屏障之后,使 curl 读取超时;提交后断连的 curl 返回 52,按相同键查询仍得到已提交订单,同键跨 stub 进程重启重试只保留同一供应商订单。**这些是 curl→stub 的结果,不是 Jakarta REST Client 的超时设置或本地任务 UNKNOWN→CONFIRMED 的验证。**将来正常集成要检查 2xx 与本地状态,错误响应要区分可重试 5xx 和业务 4xx;最关键的“成功后断连”要用真实网关先保留 UNKNOWN,按同一键查询或重试,再由持久结果决定是否确认。对端若无幂等/查询契约只能报未知。操作合同见 供应商实验合同。出站应用路径、请求预算和生产供应商均 NOT_RUN。
客户端和 stub 要说同一种“成功”
为复跑故障,现有 stub 提供 POST /orders、GET /orders/by-key/{key},默认监听 127.0.0.1:18089,按 (supplierId, Idempotency-Key) 将订单与请求次数保存在本地 TSV 账本;手动编译和启动命令见独立工程说明。下面两条命令只在启动 stub 后可执行,不能用当前采购 WAR 代替服务端进程。相同键与载荷的第二次 POST 返回关联同一供应商订单;同键不同载荷返回 409,不覆盖旧记录。版本、账本摘要、故障开关与每次请求日志必须随验收记录保存;此次自检的输出与 SHA 已归档,但不是 Java EE 出站网关的运行结果。
1 | |
如果未来采用别的 URI、认证方式或供应商 API,必须同步更新这些命令和出站网关,而不是在失败时私自切换键。这两条命令是本机启动 stub 后的正常路由检查;curl 成功仍不能代替实际 Java 出站网关的超时测试。断连路径由 stubs/supplier/control.sh 在写盘后关闭连接,随后查询 stub 账本;curl --fail-with-body 的非零退出码不证明“没有订单”。在独立工作机上访问公网供应商尤其不能直接复制该教学明文端点。
状态记录还应分开“请求可再试”和“业务已拒绝”。假设第一次收到 429:根据配置退避并计算剩余窗口,再尝试相同键;第二次响应体丢失,则状态是 UNKNOWN,不能把第一次 429 当作本次业务最终拒绝。假设收到格式正确的 202:除非合同承诺 202 代表已提交采购单,否则应保存对端操作 ID 并轮询其最终状态。成功收到 200 却无法核对响应订单 ID,与断连一样不应直接标 CONFIRMED。教学 stub 要覆盖这三种“状态码看起来正常、业务证据却不完整”的情形,否则容易把 HTTP 接口测试与业务账本测试混为一谈。测试还应同时保留客户端单调时钟统计的整体耗时与 stub 服务器记录的处理时段;两机时钟偏差会让日志时间戳无法直接相减,不能拿一条耗时曲线倒推请求从未进入供应商。
更深的竞态发生在对端成功、本地写确认前:查询供应商发现订单存在,但消费者重启仍看到本地 UNKNOWN。新的尝试不能换一个幂等键,它应先带原键重查,再在独立数据库事务中写入结果;若该事务失败,保留本地任务供下一次对账。写入 CONFIRMED 后还需要核对最终通知或回执,不以 HTTP 调用发生的次数代替业务订单数。若按订单金额设置供应商额度,客户端重试前应重新读取已核实的订单状态和额度规则,以免重复请求在过期授权下继续运行。若一笔订单需要分别通知两个供应商,两个独立效果必须各有稳定键和独立结果记录;用单个 task_id 的“已完成”标志覆盖两个外部副作用,会把其中一家的失败藏在另一家的成功之下。结果查询必须与目标供应商绑定,不能只拿到任意同名订单就认定该任务已完成。
两道练习
练习一:供应商返回 500,是否一定可以立刻换一个请求 ID 再发?解:不可以。500 可能发生在其业务提交之后;先按原幂等键查询,必要时以同一键在次数/时间预算内重试。更换键会破坏对端的重复保护。
练习二:将出站 HTTP 调用放入 @Transactional 方法,读取超时抛异常导致本地回滚,供应商是否也回滚?解:不会。普通 HTTP 服务器不是该数据库事务的参与者;对端可能已经完成。业务上应保留未知状态和可核对的任务,避免借异常清除所有线索。
隔离的成功后断连实验还要核实凭证与传输层:stub 地址只用于本机、凭证只通过运行环境注入,客户端不要记录完整授权头。若正式供应商使用 TLS,握手失败必须作为独立故障类别保存;跳过证书校验虽可能“修好”连接,却使订单及身份信息暴露,不能拿它当生产故障恢复方案。证书更新期间同一幂等键的查询能力仍需保持,否则连接恢复后仍无法判定之前的未知结果。
边界与版本资料
这里假定供应商 stub 可按键查验,正式供应商须另行取得其超时、鉴权、幂等和状态查询合同。HTTP API 的可用性不等于具体实现的连接池/超时配置已测试。官方入口:Jakarta RESTful Web Services 4.0、4.0 ClientBuilder 超时 API、4.0 Client API;版本与实测边界见系列版本表。





