This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 4327e4e25f [api] Resume truncated HTTP GET response bodies (#9271)
4327e4e25f is described below

commit 4327e4e25f49ac78842be211da124f1fae15be27
Author: wangwj <[email protected]>
AuthorDate: Thu Aug 20 10:13:15 2026 +0800

    [api] Resume truncated HTTP GET response bodies (#9271)
---
 .../org/apache/paimon/rest/HttpClientUtils.java    | 537 ++++++++++++++++++++-
 .../apache/paimon/rest/HttpClientUtilsTest.java    | 519 ++++++++++++++++++++
 .../paimon/flink/HttpBlobBodyResumeITCase.java     | 188 ++++++++
 3 files changed, 1227 insertions(+), 17 deletions(-)

diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
index e4334e60e5..1444c8118d 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
@@ -27,6 +27,7 @@ import org.apache.hc.client5.http.classic.methods.HttpGet;
 import org.apache.hc.client5.http.classic.methods.HttpHead;
 import org.apache.hc.client5.http.classic.methods.HttpPost;
 import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.entity.DecompressingEntity;
 import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
 import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
 import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
@@ -36,18 +37,36 @@ import 
org.apache.hc.client5.http.io.HttpClientConnectionManager;
 import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
 import org.apache.hc.client5.http.ssl.HttpsSupport;
 import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ConnectionClosedException;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpHeaders;
 import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.TruncatedChunkException;
 import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
 import org.apache.hc.core5.ssl.SSLContexts;
 import org.apache.hc.core5.util.Timeout;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /** Utils for {@link HttpClientBuilder}. */
 public class HttpClientUtils {
 
+    private static final int MAX_BODY_RESUME_ATTEMPTS = 5;
+    private static final Pattern CONTENT_RANGE_PATTERN =
+            Pattern.compile("bytes\\s+(\\d+)-(\\d+)/(\\d+|\\*)", 
Pattern.CASE_INSENSITIVE);
+    private static final RequestConfig DEFAULT_REQUEST_CONFIG =
+            RequestConfig.custom()
+                    .setConnectionRequestTimeout(Timeout.ofMinutes(3))
+                    .setResponseTimeout(Timeout.ofMinutes(3))
+                    .build();
+
     public static final CloseableHttpClient DEFAULT_HTTP_CLIENT = 
createLoggingBuilder().build();
 
     public static HttpClientBuilder createLoggingBuilder() {
@@ -60,12 +79,7 @@ public class HttpClientUtils {
 
     public static HttpClientBuilder createBuilder() {
         HttpClientBuilder clientBuilder = HttpClients.custom();
-        RequestConfig requestConfig =
-                RequestConfig.custom()
-                        .setConnectionRequestTimeout(Timeout.ofMinutes(3))
-                        .setResponseTimeout(Timeout.ofMinutes(3))
-                        .build();
-        clientBuilder.setDefaultRequestConfig(requestConfig);
+        clientBuilder.setDefaultRequestConfig(DEFAULT_REQUEST_CONFIG);
 
         clientBuilder.setConnectionManager(configureConnectionManager());
         clientBuilder.setRetryStrategy(new 
ExponentialHttpRequestRetryStrategy(5));
@@ -91,17 +105,7 @@ public class HttpClientUtils {
     }
 
     public static InputStream getAsInputStream(String uri) throws IOException {
-        HttpGet httpGet = newHttpGet(uri);
-        CloseableHttpResponse response = execute(httpGet, uri);
-        int statusCode = response.getCode();
-        if (statusCode != HttpStatus.SC_OK) {
-            try {
-                throw httpError(statusCode);
-            } finally {
-                response.close();
-            }
-        }
-        return response.getEntity().getContent();
+        return new ResumableHttpInputStream(uri);
     }
 
     /**
@@ -250,4 +254,503 @@ public class HttpClientUtils {
     private static RuntimeException httpError(int statusCode) {
         return new RuntimeException("HTTP error code: " + statusCode);
     }
+
+    /**
+     * An HTTP stream which resumes a prematurely closed response body from 
the last byte already
+     * returned to the caller.
+     *
+     * <p>The request retry strategy only covers failures before response 
headers are returned. A
+     * {@link ConnectionClosedException} or {@link TruncatedChunkException} 
can instead be raised
+     * while the entity stream is consumed. Replaying the whole response would 
duplicate bytes
+     * already written by the caller. A strong ETag allows a byte range 
continuation. Without one, a
+     * complete response is replayed and its already-delivered prefix is 
verified before reading
+     * continues.
+     */
+    private static class ResumableHttpInputStream extends InputStream {
+
+        private final String uri;
+        private final byte[] singleByte = new byte[1];
+
+        private CloseableHttpResponse response;
+        private InputStream stream;
+        private long position;
+        private long contentLength = -1L;
+        private long currentResponseEndExclusive = Long.MAX_VALUE;
+        private String strongEtag;
+        private boolean identityEncoded;
+        private int resumeAttempts;
+        private boolean closed;
+        private IOException terminalFailure;
+        private final MessageDigest deliveredDigest = sha256();
+
+        private ResumableHttpInputStream(String uri) throws IOException {
+            this.uri = uri;
+            openInitialResponse();
+        }
+
+        @Override
+        public int read() throws IOException {
+            int bytesRead = read(singleByte, 0, 1);
+            return bytesRead < 0 ? -1 : singleByte[0] & 0xff;
+        }
+
+        @Override
+        public int read(byte[] bytes, int offset, int length) throws 
IOException {
+            if (closed) {
+                throw new IOException("HTTP response stream is closed.");
+            }
+            if (terminalFailure != null) {
+                throw new IOException(terminalFailure.getMessage());
+            }
+            if (bytes == null) {
+                throw new NullPointerException("bytes");
+            }
+            if (offset < 0 || length < 0 || length > bytes.length - offset) {
+                throw new IndexOutOfBoundsException();
+            }
+            if (length == 0) {
+                return 0;
+            }
+
+            while (true) {
+                try {
+                    if (position == currentResponseEndExclusive) {
+                        if (contentLength >= 0 && position < contentLength) {
+                            resumeOrFail("range response ended before the 
complete resource");
+                            continue;
+                        }
+                        return -1;
+                    }
+
+                    int readLength =
+                            (int)
+                                    Math.min(
+                                            length,
+                                            Math.min(
+                                                    Integer.MAX_VALUE,
+                                                    
currentResponseEndExclusive - position));
+                    int bytesRead = stream.read(bytes, offset, readLength);
+                    if (bytesRead > 0) {
+                        if (strongEtag == null) {
+                            deliveredDigest.update(bytes, offset, bytesRead);
+                        }
+                        position += bytesRead;
+                        if (contentLength >= 0 && position > contentLength) {
+                            throw fail("response body exceeded its declared 
length");
+                        }
+                        return bytesRead;
+                    }
+                    if (bytesRead < 0 && contentLength >= 0 && position < 
contentLength) {
+                        resumeOrFail("response body ended before its declared 
length");
+                        continue;
+                    }
+                    return bytesRead;
+                } catch (ConnectionClosedException | TruncatedChunkException 
e) {
+                    resumeOrFail("response body was closed before it was fully 
consumed");
+                }
+            }
+        }
+
+        @Override
+        public int available() throws IOException {
+            if (closed) {
+                return 0;
+            }
+            if (terminalFailure != null) {
+                throw new IOException(terminalFailure.getMessage());
+            }
+            return stream == null ? 0 : stream.available();
+        }
+
+        @Override
+        public void close() throws IOException {
+            if (!closed) {
+                closed = true;
+                closeCurrentResponse();
+            }
+        }
+
+        private void openInitialResponse() throws IOException {
+            HttpGet request = newBodyGet(uri);
+            CloseableHttpResponse newResponse = execute(request, uri);
+            boolean accepted = false;
+            try {
+                if (newResponse.getCode() == HttpStatus.SC_NOT_ACCEPTABLE) {
+                    closeQuietly(newResponse);
+                    accepted = true;
+                    openContentDecodedResponse();
+                    return;
+                }
+                if (newResponse.getCode() != HttpStatus.SC_OK) {
+                    throw httpError(newResponse.getCode());
+                }
+
+                HttpEntity entity = requireEntity(newResponse);
+                if (entity instanceof DecompressingEntity || 
!isIdentityEncoded(newResponse)) {
+                    response = newResponse;
+                    stream = entity.getContent();
+                    contentLength =
+                            entity instanceof DecompressingEntity ? -1L : 
entity.getContentLength();
+                    currentResponseEndExclusive =
+                            contentLength < 0 ? Long.MAX_VALUE : contentLength;
+                    strongEtag = null;
+                    identityEncoded = false;
+                    accepted = true;
+                    return;
+                }
+
+                response = newResponse;
+                stream = entity.getContent();
+                contentLength = entity.getContentLength();
+                currentResponseEndExclusive = contentLength < 0 ? 
Long.MAX_VALUE : contentLength;
+                strongEtag = responseStrongEtag(newResponse);
+                identityEncoded = true;
+                accepted = true;
+            } finally {
+                if (!accepted) {
+                    closeQuietly(newResponse);
+                }
+            }
+        }
+
+        private void resumeOrFail(String reason) throws IOException {
+            try {
+                resume(reason);
+            } catch (IOException e) {
+                terminalFailure = e;
+                discardCurrentResponse();
+                throw e;
+            } catch (RuntimeException e) {
+                Integer statusCode = getHttpStatusCode(e);
+                terminalFailure =
+                        readFailure(
+                                statusCode == null
+                                        ? "response restart failed"
+                                        : "server returned HTTP "
+                                                + statusCode
+                                                + " while restarting the 
response");
+                discardCurrentResponse();
+                throw terminalFailure;
+            }
+        }
+
+        private void resume(String reason) throws IOException {
+            if (resumeAttempts >= MAX_BODY_RESUME_ATTEMPTS) {
+                throw readFailure(
+                        reason + " after " + MAX_BODY_RESUME_ATTEMPTS + " 
resume attempts");
+            }
+            resumeAttempts++;
+            discardCurrentResponse();
+
+            if (position == 0) {
+                openInitialResponse();
+                return;
+            }
+            if (!identityEncoded) {
+                throw readFailure("encoded response bodies cannot be resumed 
safely");
+            }
+            if (strongEtag == null) {
+                replayFromStart();
+                return;
+            }
+
+            HttpGet request = newBodyGet(uri);
+            request.addHeader(HttpHeaders.RANGE, "bytes=" + position + "-");
+            request.addHeader(HttpHeaders.IF_RANGE, strongEtag);
+
+            CloseableHttpResponse newResponse = execute(request, uri);
+            boolean accepted = false;
+            try {
+                if (newResponse.getCode() != HttpStatus.SC_PARTIAL_CONTENT) {
+                    throw readFailure(
+                            "server did not honor the range request (HTTP "
+                                    + newResponse.getCode()
+                                    + ")");
+                }
+
+                Range range = parseContentRange(newResponse);
+                if (range.start != position) {
+                    throw readFailure(
+                            "server resumed at byte " + range.start + " 
instead of " + position);
+                }
+                if (contentLength >= 0 && range.total != contentLength) {
+                    throw readFailure(
+                            "resource length changed from " + contentLength + 
" to " + range.total);
+                }
+                if (contentLength < 0) {
+                    contentLength = range.total;
+                }
+
+                HttpEntity entity = requireEntity(newResponse);
+                long rangeLength = range.end - range.start + 1;
+                if (entity instanceof DecompressingEntity || 
!isIdentityEncoded(newResponse)) {
+                    throw readFailure("range response uses a content 
encoding");
+                }
+                if (entity.getContentLength() >= 0 && 
entity.getContentLength() != rangeLength) {
+                    throw readFailure("range response length does not match 
Content-Range");
+                }
+
+                if (hasDifferentStrongEtag(newResponse, strongEtag)) {
+                    throw readFailure("strong ETag changed while resuming");
+                }
+
+                response = newResponse;
+                stream = entity.getContent();
+                currentResponseEndExclusive = range.end + 1;
+                accepted = true;
+            } finally {
+                if (!accepted) {
+                    closeQuietly(newResponse);
+                }
+            }
+        }
+
+        /**
+         * Replays a response without a strong ETag from byte zero and 
verifies that its prefix is
+         * identical to the bytes already returned. Once the prefix matches, 
continuing with the
+         * same response cannot combine bytes from two different 
representations.
+         */
+        private void replayFromStart() throws IOException {
+            byte[] expectedPrefixDigest = digestSnapshot(deliveredDigest);
+            while (true) {
+                HttpGet request = newBodyGet(uri);
+                CloseableHttpResponse newResponse = execute(request, uri);
+                boolean accepted = false;
+                try {
+                    if (newResponse.getCode() != HttpStatus.SC_OK) {
+                        throw readFailure(
+                                "server returned HTTP "
+                                        + newResponse.getCode()
+                                        + " while replaying the response");
+                    }
+                    HttpEntity entity = requireEntity(newResponse);
+                    if (entity instanceof DecompressingEntity || 
!isIdentityEncoded(newResponse)) {
+                        throw readFailure("replayed response uses a content 
encoding");
+                    }
+                    long replayedLength = entity.getContentLength();
+                    if (contentLength >= 0
+                            && replayedLength >= 0
+                            && contentLength != replayedLength) {
+                        throw readFailure(
+                                "resource length changed from "
+                                        + contentLength
+                                        + " to "
+                                        + replayedLength);
+                    }
+
+                    InputStream newStream = entity.getContent();
+                    verifyReplayedPrefix(newStream, expectedPrefixDigest);
+                    contentLength = replayedLength;
+                    response = newResponse;
+                    stream = newStream;
+                    currentResponseEndExclusive =
+                            replayedLength < 0 ? Long.MAX_VALUE : 
replayedLength;
+                    strongEtag = responseStrongEtag(newResponse);
+                    identityEncoded = true;
+                    accepted = true;
+                    return;
+                } catch (ConnectionClosedException | TruncatedChunkException 
e) {
+                    if (resumeAttempts >= MAX_BODY_RESUME_ATTEMPTS) {
+                        throw readFailure(
+                                "response replay failed after "
+                                        + MAX_BODY_RESUME_ATTEMPTS
+                                        + " resume attempts");
+                    }
+                    resumeAttempts++;
+                } finally {
+                    if (!accepted) {
+                        closeQuietly(newResponse);
+                    }
+                }
+            }
+        }
+
+        private void verifyReplayedPrefix(InputStream newStream, byte[] 
expectedPrefixDigest)
+                throws IOException {
+            MessageDigest replayedDigest = sha256();
+            byte[] buffer = new byte[8192];
+            long remaining = position;
+            while (remaining > 0) {
+                int bytesRead = newStream.read(buffer, 0, (int) 
Math.min(buffer.length, remaining));
+                if (bytesRead < 0) {
+                    throw new ConnectionClosedException(
+                            "Response ended before the previously delivered 
prefix.");
+                }
+                if (bytesRead == 0) {
+                    throw new IOException("HTTP response returned zero bytes 
while replaying.");
+                }
+                replayedDigest.update(buffer, 0, bytesRead);
+                remaining -= bytesRead;
+            }
+            if (!MessageDigest.isEqual(expectedPrefixDigest, 
replayedDigest.digest())) {
+                throw readFailure("resource content changed while replaying 
the response");
+            }
+        }
+
+        /**
+         * Preserves the old transparent content-decoding behavior for a 
server which ignores the
+         * identity request. Encoded response bodies cannot use byte-offset 
recovery because their
+         * decoded positions do not match wire byte ranges.
+         */
+        private void openContentDecodedResponse() throws IOException {
+            HttpGet request = newHttpGet(uri);
+            CloseableHttpResponse newResponse = execute(request, uri);
+            boolean accepted = false;
+            try {
+                if (newResponse.getCode() != HttpStatus.SC_OK) {
+                    throw httpError(newResponse.getCode());
+                }
+
+                HttpEntity entity = requireEntity(newResponse);
+                response = newResponse;
+                stream = entity.getContent();
+                contentLength = -1L;
+                currentResponseEndExclusive = Long.MAX_VALUE;
+                strongEtag = null;
+                identityEncoded = false;
+                accepted = true;
+            } finally {
+                if (!accepted) {
+                    closeQuietly(newResponse);
+                }
+            }
+        }
+
+        private void closeCurrentResponse() throws IOException {
+            stream = null;
+            if (response != null) {
+                try {
+                    response.close();
+                } finally {
+                    response = null;
+                }
+            }
+        }
+
+        private void discardCurrentResponse() {
+            try {
+                closeCurrentResponse();
+            } catch (IOException ignored) {
+                // The response is being discarded precisely because its body 
is incomplete.
+            }
+        }
+
+        private IOException readFailure(String reason) {
+            return new IOException(
+                    "Failed to resume HTTP response for uri: "
+                            + SensitiveConfigUtils.sanitizeUri(uri)
+                            + "; position="
+                            + position
+                            + ", contentLength="
+                            + contentLength
+                            + ", recoveryAttempts="
+                            + resumeAttempts
+                            + "; "
+                            + reason);
+        }
+
+        private IOException fail(String reason) {
+            terminalFailure = readFailure(reason);
+            discardCurrentResponse();
+            return terminalFailure;
+        }
+    }
+
+    private static HttpEntity requireEntity(CloseableHttpResponse response) 
throws IOException {
+        HttpEntity entity = response.getEntity();
+        if (entity == null) {
+            throw new IOException("HTTP response has no entity.");
+        }
+        return entity;
+    }
+
+    private static HttpGet newBodyGet(String uri) {
+        HttpGet request = newHttpGet(uri);
+        request.addHeader(HttpHeaders.ACCEPT_ENCODING, "identity");
+        return request;
+    }
+
+    private static String responseStrongEtag(CloseableHttpResponse response) {
+        Header etag = response.getFirstHeader(HttpHeaders.ETAG);
+        if (etag == null || etag.getValue() == null) {
+            return null;
+        }
+
+        String value = etag.getValue().trim();
+        return value.length() >= 2
+                        && value.charAt(0) == '"'
+                        && value.charAt(value.length() - 1) == '"'
+                        && value.indexOf('"', 1) == value.length() - 1
+                ? value
+                : null;
+    }
+
+    private static boolean hasDifferentStrongEtag(
+            CloseableHttpResponse response, String expectedStrongEtag) {
+        Header etag = response.getFirstHeader(HttpHeaders.ETAG);
+        return etag != null && 
!expectedStrongEtag.equals(responseStrongEtag(response));
+    }
+
+    private static boolean isIdentityEncoded(CloseableHttpResponse response) {
+        Header contentEncoding = 
response.getFirstHeader(HttpHeaders.CONTENT_ENCODING);
+        return contentEncoding == null
+                || 
"identity".equalsIgnoreCase(contentEncoding.getValue().trim());
+    }
+
+    private static void closeQuietly(CloseableHttpResponse response) {
+        try {
+            response.close();
+        } catch (IOException ignored) {
+            // Best effort cleanup while preserving the original failure.
+        }
+    }
+
+    private static MessageDigest sha256() {
+        try {
+            return MessageDigest.getInstance("SHA-256");
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SHA-256 is not available.", e);
+        }
+    }
+
+    private static byte[] digestSnapshot(MessageDigest digest) throws 
IOException {
+        try {
+            return ((MessageDigest) digest.clone()).digest();
+        } catch (CloneNotSupportedException e) {
+            throw new IOException("SHA-256 digest cannot be cloned.");
+        }
+    }
+
+    private static Range parseContentRange(CloseableHttpResponse response) 
throws IOException {
+        Header header = response.getFirstHeader(HttpHeaders.CONTENT_RANGE);
+        Matcher matcher =
+                header == null ? null : 
CONTENT_RANGE_PATTERN.matcher(header.getValue().trim());
+        if (matcher == null || !matcher.matches() || 
"*".equals(matcher.group(3))) {
+            throw new IOException("Invalid Content-Range in HTTP resume 
response.");
+        }
+        try {
+            long start = Long.parseLong(matcher.group(1));
+            long end = Long.parseLong(matcher.group(2));
+            long total = Long.parseLong(matcher.group(3));
+            if (start < 0 || end < start || total <= end) {
+                throw new IOException("Invalid Content-Range in HTTP resume 
response.");
+            }
+            return new Range(start, end, total);
+        } catch (NumberFormatException e) {
+            throw new IOException("Invalid Content-Range in HTTP resume 
response.");
+        }
+    }
+
+    private static class Range {
+
+        private final long start;
+        private final long end;
+        private final long total;
+
+        private Range(long start, long end, long total) {
+            this.start = start;
+            this.end = end;
+            this.total = total;
+        }
+    }
 }
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
index cb56cc1fae..5b1c3c2700 100644
--- a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
@@ -28,10 +28,16 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.InetSocketAddress;
+import java.util.Arrays;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.zip.GZIPOutputStream;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -250,6 +256,468 @@ public class HttpClientUtilsTest {
         }
     }
 
+    @Test
+    public void testGetAsInputStreamPreservesContentDecoding() throws 
Exception {
+        byte[] payload = payload(4096);
+        byte[] compressed = gzip(payload);
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/encoded",
+                exchange -> {
+                    if (requestCount.incrementAndGet() > 1) {
+                        respond(exchange, 410, new byte[0]);
+                        return;
+                    }
+                    exchange.getResponseHeaders().add("Content-Encoding", 
"gzip");
+                    respond(exchange, 200, compressed);
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/encoded"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(1);
+    }
+
+    @Test
+    public void testGetAsInputStreamFallsBackWhenIdentityEncodingIsRejected() 
throws Exception {
+        byte[] payload = payload(4096);
+        byte[] compressed = gzip(payload);
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> firstAcceptEncoding = new AtomicReference<>();
+        AtomicReference<String> secondAcceptEncoding = new AtomicReference<>();
+        registerHandler(
+                "/encoded-only",
+                exchange -> {
+                    int currentRequest = requestCount.incrementAndGet();
+                    String acceptEncoding =
+                            
exchange.getRequestHeaders().getFirst("Accept-Encoding");
+                    if (currentRequest == 1) {
+                        firstAcceptEncoding.set(acceptEncoding);
+                        respond(exchange, 406, new byte[0]);
+                        return;
+                    }
+
+                    secondAcceptEncoding.set(acceptEncoding);
+                    exchange.getResponseHeaders().add("Content-Encoding", 
"gzip");
+                    respond(exchange, 200, compressed);
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/encoded-only"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(2);
+        assertThat(firstAcceptEncoding).hasValue("identity");
+        assertThat(secondAcceptEncoding.get()).contains("gzip");
+    }
+
+    @Test
+    public void testGetAsInputStreamDoesNotResumeTruncatedEncodedResponse() 
throws Exception {
+        byte[] compressed = gzip(payload(4096));
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/truncated-encoded",
+                exchange -> {
+                    requestCount.incrementAndGet();
+                    exchange.getResponseHeaders().add("Content-Encoding", 
"gzip");
+                    respondTruncated(
+                            exchange, 200, compressed.length, compressed, 
compressed.length / 2);
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/truncated-encoded"))) {
+            assertThatThrownBy(() -> readAll(in))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("encoded response bodies cannot be 
resumed safely");
+        }
+        assertThat(requestCount).hasValue(1);
+    }
+
+    @Test
+    public void testGetAsInputStreamResumesTruncatedResponseBody() throws 
Exception {
+        byte[] payload = payload(128 * 1024);
+        int truncatedLength = 89_075;
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> range = new AtomicReference<>();
+        AtomicReference<String> ifRange = new AtomicReference<>();
+        AtomicReference<String> acceptEncoding = new AtomicReference<>();
+        registerHandler(
+                "/truncated",
+                exchange -> {
+                    int currentRequest = requestCount.incrementAndGet();
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (currentRequest == 1) {
+                        respondTruncated(exchange, payload, truncatedLength);
+                        return;
+                    }
+
+                    range.set(exchange.getRequestHeaders().getFirst("Range"));
+                    
ifRange.set(exchange.getRequestHeaders().getFirst("If-Range"));
+                    
acceptEncoding.set(exchange.getRequestHeaders().getFirst("Accept-Encoding"));
+                    byte[] remaining = Arrays.copyOfRange(payload, 
truncatedLength, payload.length);
+                    exchange.getResponseHeaders()
+                            .add(
+                                    "Content-Range",
+                                    String.format(
+                                            "bytes %d-%d/%d",
+                                            truncatedLength, payload.length - 
1, payload.length));
+                    respond(exchange, 206, remaining);
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/truncated"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(2);
+        assertThat(range).hasValue("bytes=" + truncatedLength + "-");
+        assertThat(ifRange).hasValue("\"image-v1\"");
+        assertThat(acceptEncoding).hasValue("identity");
+    }
+
+    @Test
+    public void 
testGetAsInputStreamReplaysAndVerifiesWithoutResourceValidator() throws 
Exception {
+        byte[] payload = payload(4096);
+        int truncatedLength = 1024;
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> range = new AtomicReference<>();
+        AtomicReference<String> acceptEncoding = new AtomicReference<>();
+        registerHandler(
+                "/truncated-no-validator",
+                exchange -> {
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, truncatedLength);
+                        return;
+                    }
+
+                    range.set(exchange.getRequestHeaders().getFirst("Range"));
+                    
acceptEncoding.set(exchange.getRequestHeaders().getFirst("Accept-Encoding"));
+                    respond(exchange, 200, payload);
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/truncated-no-validator"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(2);
+        assertThat(range.get()).isNull();
+        assertThat(acceptEncoding).hasValue("identity");
+    }
+
+    @Test
+    public void 
testGetAsInputStreamReadsChunkedReplayPastInitialContentLength() throws 
Exception {
+        byte[] replayedPayload = payload(120);
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/longer-chunked-replay",
+                exchange -> {
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, 200, 100, replayedPayload, 
40);
+                    } else {
+                        respondChunked(exchange, 200, replayedPayload);
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/longer-chunked-replay"))) {
+            assertThat(readAll(in)).isEqualTo(replayedPayload);
+        }
+        assertThat(requestCount).hasValue(2);
+    }
+
+    @Test
+    public void testGetAsInputStreamReplaysWhenOnlyLastModifiedIsAvailable() 
throws Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> range = new AtomicReference<>();
+        AtomicReference<String> ifRange = new AtomicReference<>();
+        registerHandler(
+                "/last-modified-only",
+                exchange -> {
+                    exchange.getResponseHeaders()
+                            .add("Last-Modified", "Mon, 17 Aug 2026 00:00:00 
GMT");
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                    } else {
+                        
range.set(exchange.getRequestHeaders().getFirst("Range"));
+                        
ifRange.set(exchange.getRequestHeaders().getFirst("If-Range"));
+                        respond(exchange, 200, payload);
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/last-modified-only"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(2);
+        assertThat(range.get()).isNull();
+        assertThat(ifRange.get()).isNull();
+    }
+
+    @Test
+    public void testGetAsInputStreamReplaysWhenOnlyWeakEtagIsAvailable() 
throws Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> range = new AtomicReference<>();
+        AtomicReference<String> ifRange = new AtomicReference<>();
+        registerHandler(
+                "/weak-etag",
+                exchange -> {
+                    exchange.getResponseHeaders().add("ETag", 
"W/\"image-v1\"");
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                    } else {
+                        
range.set(exchange.getRequestHeaders().getFirst("Range"));
+                        
ifRange.set(exchange.getRequestHeaders().getFirst("If-Range"));
+                        respond(exchange, 200, payload);
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/weak-etag"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(2);
+        assertThat(range.get()).isNull();
+        assertThat(ifRange.get()).isNull();
+    }
+
+    @Test
+    public void 
testGetAsInputStreamRejectsChangedReplayWithoutResourceValidator()
+            throws Exception {
+        byte[] payload = payload(4096);
+        byte[] changedPayload = payload.clone();
+        changedPayload[17] ^= 1;
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/changed-replay",
+                exchange -> {
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                    } else {
+                        respond(exchange, 200, changedPayload);
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/changed-replay"))) {
+            assertThatThrownBy(() -> readAll(in))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("resource content changed");
+        }
+        assertThat(requestCount).hasValue(2);
+    }
+
+    @Test
+    public void testGetAsInputStreamResumesMultipleTruncatedBodies() throws 
Exception {
+        byte[] payload = payload(16 * 1024);
+        int firstEnd = 2048;
+        int secondEnd = 3072;
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> firstRange = new AtomicReference<>();
+        AtomicReference<String> secondRange = new AtomicReference<>();
+        registerHandler(
+                "/multiple-truncations",
+                exchange -> {
+                    int currentRequest = requestCount.incrementAndGet();
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (currentRequest == 1) {
+                        respondTruncated(exchange, payload, firstEnd);
+                        return;
+                    }
+
+                    int start = currentRequest == 2 ? firstEnd : secondEnd;
+                    if (currentRequest == 2) {
+                        
firstRange.set(exchange.getRequestHeaders().getFirst("Range"));
+                    } else {
+                        
secondRange.set(exchange.getRequestHeaders().getFirst("Range"));
+                    }
+                    exchange.getResponseHeaders()
+                            .add(
+                                    "Content-Range",
+                                    String.format(
+                                            "bytes %d-%d/%d",
+                                            start, payload.length - 1, 
payload.length));
+                    byte[] remaining = Arrays.copyOfRange(payload, start, 
payload.length);
+                    if (currentRequest == 2) {
+                        respondTruncated(exchange, 206, remaining.length, 
remaining, 1024);
+                    } else {
+                        respond(exchange, 206, remaining);
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/multiple-truncations"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(3);
+        assertThat(firstRange).hasValue("bytes=" + firstEnd + "-");
+        assertThat(secondRange).hasValue("bytes=" + secondEnd + "-");
+    }
+
+    @Test
+    public void testGetAsInputStreamContinuesAfterBoundedRangeResponse() 
throws Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> lastRange = new AtomicReference<>();
+        registerHandler(
+                "/bounded-range",
+                exchange -> {
+                    int currentRequest = requestCount.incrementAndGet();
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (currentRequest == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                    } else if (currentRequest == 2) {
+                        exchange.getResponseHeaders().add("Content-Range", 
"bytes 1024-2047/4096");
+                        respond(exchange, 206, Arrays.copyOfRange(payload, 
1024, 2048));
+                    } else {
+                        
lastRange.set(exchange.getRequestHeaders().getFirst("Range"));
+                        exchange.getResponseHeaders().add("Content-Range", 
"bytes 2048-4095/4096");
+                        respond(exchange, 206, Arrays.copyOfRange(payload, 
2048, 4096));
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/bounded-range"))) {
+            assertThat(readAll(in)).isEqualTo(payload);
+        }
+        assertThat(requestCount).hasValue(3);
+        assertThat(lastRange).hasValue("bytes=2048-");
+    }
+
+    @Test
+    public void testGetAsInputStreamFailsWhenServerIgnoresRange() throws 
Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/ignore-range",
+                exchange -> {
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                    } else {
+                        respond(exchange, 200, payload);
+                    }
+                });
+
+        String uri = url("/ignore-range") + "?sig=BODY_RESUME_SECRET";
+        try (InputStream in = HttpClientUtils.getAsInputStream(uri)) {
+            assertThatThrownBy(() -> readAll(in))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("did not honor the range request")
+                    .satisfies(
+                            e -> {
+                                
assertThat(String.valueOf(e)).doesNotContain("BODY_RESUME_SECRET");
+                                
assertThat(e.getMessage()).doesNotContain("sig=");
+                            });
+        }
+        assertThat(requestCount).hasValue(2);
+    }
+
+    @Test
+    public void testGetAsInputStreamFailsForMismatchedContentRange() throws 
Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/wrong-content-range",
+                exchange -> {
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                        return;
+                    }
+
+                    exchange.getResponseHeaders().add("Content-Range", "bytes 
1023-4095/4096");
+                    respond(exchange, 206, Arrays.copyOfRange(payload, 1023, 
payload.length));
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/wrong-content-range"))) {
+            assertThatThrownBy(() -> readAll(in))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("resumed at byte 1023 instead of 
1024");
+        }
+        assertThat(requestCount).hasValue(2);
+    }
+
+    @Test
+    public void testGetAsInputStreamFailsAfterBoundedResumeAttempts() throws 
Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/resume-exhausted",
+                exchange -> {
+                    int currentRequest = requestCount.incrementAndGet();
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (currentRequest == 1) {
+                        respondTruncated(exchange, payload, 1024);
+                        return;
+                    }
+
+                    int start = 1024 + currentRequest - 2;
+                    exchange.getResponseHeaders()
+                            .add(
+                                    "Content-Range",
+                                    String.format(
+                                            "bytes %d-%d/%d",
+                                            start, payload.length - 1, 
payload.length));
+                    byte[] remaining = Arrays.copyOfRange(payload, start, 
payload.length);
+                    respondTruncated(exchange, 206, remaining.length, 
remaining, 1);
+                });
+
+        String uri = url("/resume-exhausted") + "?sig=BODY_RESUME_SECRET";
+        try (InputStream in = HttpClientUtils.getAsInputStream(uri)) {
+            assertThatThrownBy(() -> readAll(in))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("after 5 resume attempts")
+                    .hasMessageContaining(url("/resume-exhausted"))
+                    .hasMessageContaining("position=1029")
+                    .hasMessageContaining("contentLength=4096")
+                    .hasMessageContaining("recoveryAttempts=5")
+                    .satisfies(
+                            e -> {
+                                
assertThat(String.valueOf(e)).doesNotContain("BODY_RESUME_SECRET");
+                                
assertThat(e.getMessage()).doesNotContain("sig=");
+                            });
+            assertThatThrownBy(in::read)
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("after 5 resume attempts");
+        }
+        assertThat(requestCount).hasValue(6);
+    }
+
+    @Test
+    public void testGetAsInputStreamDoesNotResumeAfterClose() throws Exception 
{
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/closed-stream",
+                exchange -> {
+                    requestCount.incrementAndGet();
+                    respond(exchange, 200, payload(4096));
+                });
+
+        InputStream in = 
HttpClientUtils.getAsInputStream(url("/closed-stream"));
+        assertThat(in.read()).isNotNegative();
+        in.close();
+        assertThatThrownBy(in::read)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("stream is closed");
+        assertThat(requestCount).hasValue(1);
+    }
+
+    @Test
+    public void testGetAsInputStreamKeepsRestartStatusFailureTerminal() throws 
Exception {
+        byte[] payload = payload(4096);
+        AtomicInteger requestCount = new AtomicInteger();
+        registerHandler(
+                "/restart-status",
+                exchange -> {
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, 0);
+                    } else {
+                        respond(exchange, 404, new byte[0]);
+                    }
+                });
+
+        try (InputStream in = 
HttpClientUtils.getAsInputStream(url("/restart-status"))) {
+            assertThatThrownBy(in::read)
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("server returned HTTP 404");
+            assertThatThrownBy(in::read)
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("server returned HTTP 404");
+        }
+        assertThat(requestCount).hasValue(2);
+    }
+
     @Test
     public void testIsNotFoundError() {
         RuntimeException exception =
@@ -330,4 +798,55 @@ public class HttpClientUtilsTest {
             exchange.close();
         }
     }
+
+    private static void respondTruncated(HttpExchange exchange, byte[] body, 
int truncatedLength)
+            throws IOException {
+        respondTruncated(exchange, 200, body.length, body, truncatedLength);
+    }
+
+    private static void respondTruncated(
+            HttpExchange exchange,
+            int statusCode,
+            long declaredLength,
+            byte[] body,
+            int truncatedLength)
+            throws IOException {
+        exchange.sendResponseHeaders(statusCode, declaredLength);
+        OutputStream outputStream = exchange.getResponseBody();
+        outputStream.write(body, 0, truncatedLength);
+        outputStream.flush();
+        exchange.close();
+    }
+
+    private static void respondChunked(HttpExchange exchange, int statusCode, 
byte[] body)
+            throws IOException {
+        exchange.sendResponseHeaders(statusCode, 0);
+        try (OutputStream outputStream = exchange.getResponseBody()) {
+            outputStream.write(body);
+        }
+    }
+
+    private static byte[] payload(int length) {
+        byte[] payload = new byte[length];
+        new Random(20260817L).nextBytes(payload);
+        return payload;
+    }
+
+    private static byte[] gzip(byte[] payload) throws IOException {
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        try (GZIPOutputStream gzipOutputStream = new 
GZIPOutputStream(outputStream)) {
+            gzipOutputStream.write(payload);
+        }
+        return outputStream.toByteArray();
+    }
+
+    private static byte[] readAll(InputStream inputStream) throws IOException {
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        byte[] buffer = new byte[8192];
+        int bytesRead;
+        while ((bytesRead = inputStream.read(buffer)) >= 0) {
+            outputStream.write(buffer, 0, bytesRead);
+        }
+        return outputStream.toByteArray();
+    }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/HttpBlobBodyResumeITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/HttpBlobBodyResumeITCase.java
new file mode 100644
index 0000000000..2a5c5350d2
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/HttpBlobBodyResumeITCase.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.flink.types.Row;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** End-to-end tests for resuming truncated HTTP response bodies while writing 
BLOBs. */
+public class HttpBlobBodyResumeITCase extends CatalogITCaseBase {
+
+    @Test
+    public void testHttpBlobWriteResumesWithStrongEtag() throws Exception {
+        byte[] payload = payload(128 * 1024);
+        int truncatedLength = 89_075;
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> range = new AtomicReference<>();
+        AtomicReference<String> ifRange = new AtomicReference<>();
+
+        HttpServer server = newServer();
+        server.createContext(
+                "/strong-etag",
+                exchange -> {
+                    exchange.getResponseHeaders().add("ETag", "\"image-v1\"");
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, truncatedLength);
+                        return;
+                    }
+
+                    range.set(exchange.getRequestHeaders().getFirst("Range"));
+                    
ifRange.set(exchange.getRequestHeaders().getFirst("If-Range"));
+                    byte[] remaining = Arrays.copyOfRange(payload, 
truncatedLength, payload.length);
+                    exchange.getResponseHeaders()
+                            .add(
+                                    "Content-Range",
+                                    String.format(
+                                            "bytes %d-%d/%d",
+                                            truncatedLength, payload.length - 
1, payload.length));
+                    respond(exchange, 206, remaining);
+                });
+        server.start();
+
+        try {
+            String url = url(server, "/strong-etag");
+            // A successful recovery must preserve the BLOB even when terminal 
fetch failures are
+            // configured to fall back to NULL.
+            createBlobTable("strong_etag_blob_table", true);
+            batchSql(
+                    "INSERT INTO strong_etag_blob_table VALUES"
+                            + " (1, sys.path_to_descriptor('"
+                            + url
+                            + "'))");
+
+            assertBlobEquals("strong_etag_blob_table", payload);
+            assertThat(requestCount).hasValue(2);
+            assertThat(range).hasValue("bytes=" + truncatedLength + "-");
+            assertThat(ifRange).hasValue("\"image-v1\"");
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void testHttpBlobWriteReplaysWithoutStrongEtag() throws Exception {
+        byte[] payload = payload(32 * 1024);
+        int truncatedLength = 7_321;
+        AtomicInteger requestCount = new AtomicInteger();
+        AtomicReference<String> range = new AtomicReference<>();
+        AtomicReference<String> ifRange = new AtomicReference<>();
+
+        HttpServer server = newServer();
+        server.createContext(
+                "/no-strong-etag",
+                exchange -> {
+                    if (requestCount.incrementAndGet() == 1) {
+                        respondTruncated(exchange, payload, truncatedLength);
+                        return;
+                    }
+
+                    range.set(exchange.getRequestHeaders().getFirst("Range"));
+                    
ifRange.set(exchange.getRequestHeaders().getFirst("If-Range"));
+                    respond(exchange, 200, payload);
+                });
+        server.start();
+
+        try {
+            String url = url(server, "/no-strong-etag");
+            createBlobTable("replayed_blob_table", false);
+            batchSql(
+                    "INSERT INTO replayed_blob_table VALUES"
+                            + " (1, sys.path_to_descriptor('"
+                            + url
+                            + "'))");
+
+            assertBlobEquals("replayed_blob_table", payload);
+            assertThat(requestCount).hasValue(2);
+            assertThat(range.get()).isNull();
+            assertThat(ifRange.get()).isNull();
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private void createBlobTable(String tableName, boolean 
writeNullOnFetchFailure) {
+        tEnv.executeSql(
+                String.format(
+                        "CREATE TABLE %s (id INT, picture BYTES) WITH ("
+                                + "'row-tracking.enabled'='true',"
+                                + "'data-evolution.enabled'='true',"
+                                + "'blob-field'='picture',"
+                                + "'blob-as-descriptor'='true'%s)",
+                        tableName,
+                        writeNullOnFetchFailure
+                                ? ",'blob-write-null-on-fetch-failure'='true'"
+                                : ""));
+    }
+
+    private void assertBlobEquals(String tableName, byte[] expected) {
+        batchSql("ALTER TABLE %s SET ('blob-as-descriptor'='false')", 
tableName);
+        List<Row> rows = batchSql("SELECT id, picture FROM %s", tableName);
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).getField(0)).isEqualTo(1);
+        assertThat((byte[]) rows.get(0).getField(1)).isEqualTo(expected);
+        assertThat(findLatestSnapshot(tableName)).isNotNull();
+    }
+
+    private static HttpServer newServer() throws IOException {
+        return HttpServer.create(new InetSocketAddress(0), 0);
+    }
+
+    private static String url(HttpServer server, String path) {
+        return String.format("http://localhost:%d%s";, 
server.getAddress().getPort(), path);
+    }
+
+    private static void respondTruncated(HttpExchange exchange, byte[] 
payload, int truncatedLength)
+            throws IOException {
+        exchange.sendResponseHeaders(200, payload.length);
+        OutputStream out = exchange.getResponseBody();
+        out.write(payload, 0, truncatedLength);
+        out.flush();
+        exchange.close();
+    }
+
+    private static void respond(HttpExchange exchange, int statusCode, byte[] 
payload)
+            throws IOException {
+        exchange.sendResponseHeaders(statusCode, payload.length);
+        try (OutputStream out = exchange.getResponseBody()) {
+            out.write(payload);
+        }
+    }
+
+    private static byte[] payload(int length) {
+        byte[] payload = new byte[length];
+        byte[] seed = 
"paimon-http-blob-resume".getBytes(StandardCharsets.UTF_8);
+        for (int i = 0; i < payload.length; i++) {
+            payload[i] = (byte) (seed[i % seed.length] ^ (i * 31) ^ (i >>> 8));
+        }
+        return payload;
+    }
+}

Reply via email to