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

1 阅读4分钟

退款请求超时了,代码下一步该查原单,还是再发一次?我用 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,在文件所在目录运行:

javac --add-modules jdk.httpserver RefundTimeoutLab.java
java --add-modules jdk.httpserver RefundTimeoutLab

实测输出如下:

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 网络错误说明

你们的接口如果拿到“暂时查不到”,页面会让用户再点一次退款,还是保留原单继续查?

完整代码

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 超时文档