退款请求超时了,代码下一步该查原单,还是再发一次?我用 Java 的 HTTP 客户端跑了三组本地实验。两组都遇到了同样的超时异常,一组用原退款编号查到了成功,另一组只查到"暂无记录"。还有一组直接收到了明确的失败结果。
先把提交和补查拆开
实验给每笔退款一个业务编号。客户端往 /submit/{refundId} 提交,往 /status/{refundId} 查状态。模拟端的结果存在线程安全的内存变量里。
第一组收到提交后,模拟端先把状态设为 SUCCEEDED,然后用 CountDownLatch 扣住响应,让客户端等到超时。客户端再用原编号查询。第三组也扣住提交响应,但查询返回 NOT_FOUND。第二组直接返回业务失败,用来对照。这里三个状态都是模拟端返回的业务值,NOT_FOUND 也走 HTTP 200。
运行方式和实际结果
本机使用 JDK 21.0.11。程序只用 JDK 自带的 HttpClient 和 jdk.httpserver,模拟端监听 127.0.0.1 的临时端口。提交的请求超时设为 900 毫秒,补查单独给 3 秒。
把下面的完整代码保存成 RefundTimeoutLab.java,在文件所在目录运行:
bash
javac --add-modules jdk.httpserver RefundTimeoutLab.java
java --add-modules jdk.httpserver RefundTimeoutLab
实测输出如下:
text
R-001 submit=TIMEOUT lookup=SUCCEEDED local=SUCCEEDED submits=1
R-002 submit=FAILED lookup=SKIPPED local=FAILED submits=1
R-003 submit=TIMEOUT lookup=NOT_FOUND local=PENDING_CONFIRMATION submits=1
PASS: 3 cases
| 编号 | 提交时看到什么 | 按原编号补查 | 本地如何记录 |
|---|---|---|---|
| R-001 | 客户端超时 | 退款成功 | 更新为成功 |
| R-002 | 收到明确失败响应 | 跳过补查 | 更新为失败 |
| R-003 | 客户端超时 | 这次查不到记录 | 保留待确认 |
submits=1 是模拟端实际收到的提交次数。程序在每个分支检查提交结果、补查结果、本地状态和提交次数,全部通过才打印 PASS。
查不到时先留一个待确认状态
提交一旦超时,这段代码就用原编号补查。查到明确的成功或失败,才把本地状态改为对应结果。R-003 当前查不到记录,所以本地留在 PENDING_CONFIRMATION(待确认),之后仍可以用原编号核对。R-002 在提交时就拿到了明确的失败结果,直接记为失败。
实际接入时,先确认通道允许用哪个编号查询、查不到的含义、哪些状态是终态。若通道允许重试同一笔业务,还要按它的幂等规则核对编号或幂等键、参数和有效期。这个实验演示的是状态判断,使用内存数据,范围只到一次提交和一次补查。持久化、回调验签及补查调度需要单独设计和验证。比如 Stripe 的网络错误文档就明确区分了客户端连接失败与原操作结果,并说明 API v1 重试的条件。 Stripe 网络错误说明
你们的接口如果拿到"暂时查不到",页面会让用户再点一次退款,还是保留原单继续查?
完整代码
java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
public class RefundTimeoutLab {
record Case(String refundId, String providerState, boolean holdReply,
String expectedSubmit, String expectedLookup, String expectedLocal) {}
public static void main(String[] args) throws Exception {
var cases = List.of(
new Case("R-001", "SUCCEEDED", true, "TIMEOUT", "SUCCEEDED", "SUCCEEDED"),
new Case("R-002", "FAILED", false, "FAILED", "SKIPPED", "FAILED"),
new Case("R-003", "NOT_FOUND", true, "TIMEOUT", "NOT_FOUND", "PENDING_CONFIRMATION")
);
try (var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)).build()) {
for (var test : cases) runCase(client, test);
}
System.out.println("PASS: 3 cases");
}
static void runCase(HttpClient client, Case test) throws Exception {
var submissions = new AtomicInteger();
var state = new AtomicReference<>("NOT_FOUND");
var releaseReply = new CountDownLatch(1);
var statusPath = "/status/" + test.refundId();
// 本地模拟端只监听回环地址,端口由系统分配。
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
try (var workers = Executors.newFixedThreadPool(2)) {
server.setExecutor(workers);
server.createContext("/submit/" + test.refundId(), exchange -> {
submissions.incrementAndGet();
state.set(test.providerState());
if (test.holdReply()) {
// 先设定查询可见的状态,再扣住提交响应。
// 释放时客户端已经结束等待,直接关闭连接。
try {
releaseReply.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
exchange.close();
}
return;
}
reply(exchange, test.providerState());
});
server.createContext(statusPath, exchange -> reply(exchange, state.get()));
server.start();
try {
String base = "http://127.0.0.1:" + server.getAddress().getPort();
require(lookup(client, base + statusPath).equals("NOT_FOUND"), "initial state");
String submit = submit(client, base + "/submit/" + test.refundId());
require(submissions.get() == 1, test.refundId() + " submit count");
String found = submit.equals("TIMEOUT") ? lookup(client, base + statusPath) : "SKIPPED";
String local = submit.equals("TIMEOUT") ? confirmedState(found) : submit;
require(submit.equals(test.expectedSubmit()), test.refundId() + " submit result");
require(found.equals(test.expectedLookup()), test.refundId() + " lookup result");
require(local.equals(test.expectedLocal()), test.refundId() + " local state");
require(submissions.get() == 1, test.refundId() + " no second submission");
System.out.printf("%s submit=%s lookup=%s local=%s submits=%d%n",
test.refundId(), submit, found, local, submissions.get());
} finally {
releaseReply.countDown();
server.stop(0);
}
}
}
static String submit(HttpClient client, String url) throws Exception {
var request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofMillis(900))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
try {
return send(client, request);
} catch (HttpTimeoutException e) {
return "TIMEOUT";
}
}
static String lookup(HttpClient client, String url) throws Exception {
var request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(3)).GET().build();
return send(client, request);
}
static String send(HttpClient client, HttpRequest request) throws Exception {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
require(response.statusCode() == 200, "unexpected HTTP status");
return response.body();
}
static String confirmedState(String found) {
return switch (found) {
case "SUCCEEDED", "FAILED" -> found;
default -> "PENDING_CONFIRMATION";
};
}
static void reply(HttpExchange exchange, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
try (exchange) {
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
}
}
static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}