HttpClient 流式下载:收到响应头以后,超时还管不管正文
- 发布时间:2026-10-23 21:06:52
- 本文热度:浏览 3 赞 0 评论 0
- 文章标签: Java HttpClient 流式下载 超时
- 全文共1字,阅读约需1分钟
HttpClient.send 已经返回了,下载线程却还卡在 InputStream.read。请求里明明写着一秒超时,服务端两秒后补发的字节仍能读到。这种现象要先看 BodyHandler:拿到一个可以继续读取的流,与拿到完整响应体,是两个完成时点。
下面固定 Java 17,使用 JDK 自带的 HTTP 服务构造慢响应。实验在 Corretto 17.0.15 上核对过:服务端先发送响应头和字节 A,两秒后再发送 B。第一次只设置请求超时,第二次给“发请求到读完正文”另设截止时间。代码只访问本机回环地址,不连接外部网站。
一秒请求超时,为什么还能收到两秒后的 B
BodyHandlers.ofInputStream() 返回的响应包含一条后续可读的流。此时响应头已经读完,正文可以仍在路上。调用方如果只给 send 周围的代码记耗时,统计到的主要是“流已经可供读取”,而不是“下载成功”。后续读取、文件写入与校验都没有自动进入这个统计口径。
这也解释了一个容易误导排查的现象:取得响应耗时很低,下载线程占用却越来越高。线程可能停在正文消费阶段,连接建立和响应头都没有慢。
连接超时、请求返回时限、正文整体截止时间、两次数据之间允许的空闲时间,各自约束不同动作。不能看到配置里有一个 timeout,就默认这四件事已经一起受到控制。尤其是把原来的 ofString 换成 ofInputStream 时,业务读完数据的时点已经移动到了 send 之后。
这里选择整体截止时间,规则很明确:从第二次发送前开始计算,一秒内必须读完两个字节。不是每读到一个字节就再续一秒,也不是只等到第一个字节。
把两条路径放在同一个程序里
保存为 StreamDeadline.java。需要完整 JDK 17,程序使用 jdk.httpserver 模块,不需要 Maven 和第三方依赖。
import com.sun.net.httpserver.HttpServer;
import java.io.*;
import java.net.*;
import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
public class StreamDeadline {
static byte[] readWithDeadline(InputStream in, long deadline,
int maxBytes, ScheduledExecutorService timer)
throws IOException {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) throw new HttpTimeoutException("body deadline expired");
AtomicBoolean expired = new AtomicBoolean();
ScheduledFuture<?> alarm = timer.schedule(() -> {
expired.set(true);
try { in.close(); } catch (IOException ignored) { }
}, remaining, TimeUnit.NANOSECONDS);
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
for (int n; (n = in.read(buffer)) != -1; ) {
if (n > maxBytes - out.size()) throw new IOException("body too large");
out.write(buffer, 0, n);
}
if (expired.get() || System.nanoTime() >= deadline)
throw new HttpTimeoutException("body deadline expired");
return out.toByteArray();
} catch (IOException ex) {
if (expired.get() && !(ex instanceof HttpTimeoutException)) {
HttpTimeoutException timeout = new HttpTimeoutException("body deadline expired");
timeout.initCause(ex);
throw timeout;
}
throw ex;
} finally {
alarm.cancel(false);
}
}
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
ExecutorService serverThreads = Executors.newCachedThreadPool();
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
server.setExecutor(serverThreads);
server.createContext("/slow", exchange -> {
try (OutputStream out = exchange.getResponseBody()) {
exchange.sendResponseHeaders(200, 2);
out.write('A');
out.flush();
Thread.sleep(2000);
out.write('B');
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} catch (IOException ex) {
// The deadline case deliberately closes its response stream.
} finally {
exchange.close();
}
});
server.start();
try {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/slow");
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(1)).build();
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(1)).GET().build();
HttpResponse<InputStream> first = client.send(request,
HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = first.body()) {
if (first.statusCode() != 200) throw new IOException("unexpected status");
String text = new String(body.readAllBytes(), java.nio.charset.StandardCharsets.US_ASCII);
System.out.println("request_timeout_only=" + text);
}
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
HttpResponse<InputStream> second = client.send(request,
HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = second.body()) {
if (second.statusCode() != 200) throw new IOException("unexpected status");
readWithDeadline(body, deadline, 64, timer);
throw new AssertionError("expected timeout");
} catch (HttpTimeoutException expected) {
System.out.println("whole_operation=TIMEOUT");
}
} finally {
server.stop(0);
serverThreads.shutdownNow();
timer.shutdownNow();
}
}
}
执行:
java --add-modules jdk.httpserver StreamDeadline.java
能够启动源码文件模式,说明运行的是完整 JDK。若出现不支持源码运行、找不到 java.net.http 等问题,先核对 java -version,不要在 Java 8 上补一堆同名依赖继续尝试。
核对的结果是:
request_timeout_only=AB
whole_operation=TIMEOUT
第一行的价值在于 B 确实在服务端等待以后才写出。它证明这组 Java 17、ofInputStream 与读取方式下,请求上的一秒配置没有替后续正文读取建立同样的一秒上限。第二行证明显式关闭这条响应流能够使本例阻塞读取结束,并被归类为整体超时。这里没有把运行时间写成性能数字;机器调度可能让输出晚一些出现,断言的是路径和结果。
截止时间为什么用 nanoTime
deadline 是单调计时的截止点。它用于比较经过了多少时间,不用于表示一个日历时刻。系统时间校准、时区和夏令时都不应该改变这次下载还有多少预算。
取得响应以后,只给读取阶段安排剩余预算。如果请求前半段已经耗尽预算,就直接失败,不能再额外赠送一整秒。定时器触发时先登记超时,再关闭响应流;读取路径根据这个标记把关闭导致的 I/O 异常转换成 HttpTimeoutException。真正的网络错误没有超时标记,仍保留原来的 I/O 异常,避免把所有断流都统计成超时。
成功路径也会再次核对截止点。否则最后一次读取恰好与定时器竞争,可能得到完整字节,却已经超过业务允许的总时间。alarm.cancel(false) 只是在读操作结束后取消尚未执行的闹钟,不负责终止下载工作;负责影响本例读取的是响应流的 close。
同样的办法不能照搬到任意 InputStream,再声称关闭一定能打断它。流可能由磁盘、压缩器、第三方库或自定义代码提供,各自的并发关闭行为要核查。本文验证的是 JDK HTTP 响应流。需要严格隔离不受控本地调用时,还要从库接口、进程边界或操作系统能力决定怎样终止,而不是只在结果对象上标记超时。
这份实验离下载器还差哪些业务规则
样本正文只有两个字节。第一条反例路径使用 readAllBytes,只是为了清楚看见 AB;它不适合直接接收不受控的大响应。带截止时间的方法已经增加 64 字节限额,但限额与时间预算是两道独立约束:对方也可能在一秒里快速发出超大内容。
实际写文件时,还要把输出放在临时目标中,只有字节数、摘要或业务格式校验成功才交给后续流程。超时后的半截文件不能因为已经创建了路径就被当成下载成功。若输出流本身可能长时间阻塞,本例的关闭输入并不能保证输出写入也及时返回,整体预算需要覆盖相应存储端的控制机制。
默认 BodyHandler 不替应用判断状态码。错误页也可能有完整正文,因此示例在读取之前检查 200。正式客户端还应按协议决定哪些状态可接受、错误正文最多保留多少、是否允许重定向及跨域携带凭据。它们决定“成功”的含义,不能靠读到 EOF 代替。
最后,定时线程和服务线程都在 finally 中关闭。给一次操作加了截止时间,却让每次调用创建的线程池永久存活,会把超时问题换成另一种资源积压。可复用客户端、统一容量受控的计时设施、始终关闭响应流,这些生命周期约定应和业务截止时间一起设计。