Java 退款请求超时,先查原单还是重试?

退款请求超时了,代码下一步该查原单,还是再发一次?我用 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);
    }
}

Java 21 HttpRequest.Builder 超时文档

相关推荐
特立独行的猫A16 分钟前
我用仓颉写了个多平台发文工具:一份 Markdown,一条命令发六个平台
java·后端·python
HUANGZHENXUAN12319 分钟前
代码解析(二):detect_video.py —— 一个纯函数 + 一段主循环
后端
ikgy30 分钟前
JAVA EE:1.计算机是如何工作的
java·java-ee
爱勇宝1 小时前
《道德经》第 13 章:别让评价系统控制你的心
前端·后端·产品
掘金者阿豪1 小时前
集中式与分布式数据库怎么选?从架构原理到电科金仓的实践思考
前端·后端
.痞老板.1 小时前
java ee--计算机是如何工作的
java·开发语言·java-ee
张彦峰ZYF1 小时前
升级了不等于变好了:AI回归测试的固定评测集、版本对比、质量阈值与CI门禁
人工智能·后端·模型或提示词升级·语言模型没有编译器与确定性输出·总体、切片与回退清单·红线、护栏与目标·ci门禁把判定写进合并路径
青梅味猪大肠1 小时前
【操作系统-60】临近适应算法
java·linux·开发语言·嵌入式硬件·算法·操作系统
CoderYanger1 小时前
Java EE 进阶:8.MyBatis 操作数据库(入门)(第一弹)
java·数据库·spring·面试·职场和发展·java-ee·mybatis