Skip to content

AWS, Core: Read S3 files via catalog-vended HTTPS URLs - #17457

Open
williamhyun wants to merge 13 commits into
apache:mainfrom
williamhyun:s3-presign-url
Open

AWS, Core: Read S3 files via catalog-vended HTTPS URLs#17457
williamhyun wants to merge 13 commits into
apache:mainfrom
williamhyun:s3-presign-url

Conversation

@williamhyun

@williamhyun williamhyun commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Adds support for reading object-store files over catalog-vended HTTP(S) URLs without requiring native cloud credentials on the reader, and wires it into S3FileIO.

  • Core: Introduces HttpUrlClient, HttpInputFile, and HttpInputStream — a seekable, range-readable HTTPS input path built on the httpclient5 dependency iceberg-core already depends on. Reads are served via chunked range GETs whose size is configurable via io.http.read.chunk-size-bytes (default 8 MB for S3FileIO); range fetches are retried with exponential backoff on transient network and retryable HTTP errors (e.g. 429/503), while terminal statuses (403/404) fail fast. Content length is probed via Range: bytes=0-0 when unknown, since pre-signed GET URLs typically reject HEAD. Pre-signed URLs are treated as bearer secrets and are redacted in logs.
  • AWS: S3FileIO#newInputFile(String) / newInputFile(String, long) now detect an HTTP(S) location and read it via HttpUrlClient, after validating (S3PresignedReadValidation) that it is HTTPS on an allow-listed host — s3.presigned-read.allowed-hosts (default amazonaws.com, amazonaws.com.cn, plus the configured S3 endpoint host); plain http:// or an untrusted host is rejected with ValidationException. s3:///s3a:///s3n:// reads stay on the native, credentialed S3 client path unchanged. S3FileIO#close() now also closes the shared HTTP client.
  • Tests: Unit coverage for the dispatch decision (TestS3FileIOHttpUrlDispatch injects a throwing S3 supplier to prove the native client is never invoked and that http:///untrusted hosts are rejected); host-allow-list validation (TestS3PresignedReadValidation); HttpInputFile/HttpInputStream behavior against a real local HttpServer (TestHttpInputFile, including multi-chunk reads crossing the chunk-size boundary, retry/backoff on transient statuses, terminal-status handling, and redirect-not-followed); chunk-size property parsing, per-FileIO default precedence, redaction, and client config (TestHttpUrlClient); status classification and content-length parsing (TestHttpStatusCategory, TestHttpHeaderUtil); and an optional live-AWS integration test (TestS3FileIOLivePresignedUrls) gated on AWS credential env vars.
    This is the FileIO building block for catalogs that vend a pre-signed URL as a file's file-path in scan planning, so readers can fetch bytes over HTTPS with auth encoded in the URL itself. Delete-file handling, scan-planning integration, and other cloud providers are out of scope for this PR (see below).

Scope / compatibility notes

  1. The catalog is expected to place a usable, already-signed URL directly in the file's location (file-path); the client uses that location unchanged as the fetch URL. There is no separate signing lookup or location-to-URL mapping in this PR.
  2. Reads are restricted to HTTPS on operator-trusted hosts via s3.presigned-read.allowed-hosts, bounding a compromised or malicious catalog to naming files on the expected storage hosts rather than turning the reader into a general-purpose URL fetcher.
  3. Only S3FileIO is updated. ResolvingFileIO and the GCS/ADLS equivalents are unchanged — a catalog that wants this behavior today needs to vend io-impl: S3FileIO per-table via the REST LoadTableResponse.config() map (the specified mechanism for a catalog to select a FileIO for a table), rather than relying on ResolvingFileIO's scheme-based dispatch. Extending the other cloud FileIOs to the same short-circuit pattern is a natural follow-up.
  4. Positional deletes are not supported.
  5. SplitScanTasks are already unsupported on the remote scan-plan read path, as are multiple deletion vectors in the same deletion file. These are known limitations of the remote scan-plan endpoint and are an orthogonal fix. (Core, REST: Fix delete file references for DVs in the same Puffin file #17497)
  6. Error handling has a single classification point, HttpStatusCategory.classify(int), mapping raw status codes into coarse categories; only transient categories are retried (retry is driven by exception type). A cloud-specific refiner could later inspect error-response bodies to reclassify an otherwise-terminal status (e.g. a throttling 403) as retryable, without exposing the package-private enum.

Test plan

  • Pass the CIs
  • Optionally, with AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and AWS_TEST_BUCKET set: ./gradlew :iceberg-aws:integrationTest --tests org.apache.iceberg.aws.s3.TestS3FileIOLivePresignedUrls

AI Disclosure

  • Model: Claude Opus 4.8
  • Platform/Tool: Cursor
  • Human Oversight: fully reviewed
  • Prompt Summary: Implement a delegation-based HTTP(S) read path for S3FileIO (a shared core HttpUrlClient plus per-FileIO dispatch) so readers can fetch bytes from catalog-vended pre-signed URLs without native S3 credentials, with an HTTPS host allow-list and unit/integration/live-AWS test coverage.

@singhpk234 singhpk234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work @williamhyun !

Added first round of comments

Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpUrlClient.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpUrlSupport.java Outdated
@williamhyun

Copy link
Copy Markdown
Member Author

Thank you @singhpk234 for the comments, I have pushed with the most recent changes, please feel free to review again!

@williamhyun williamhyun changed the title AWS, Core: Read S3 files via catalog-vended HTTPS location AWS, Core: Read S3 files via catalog-vended HTTPS URLs Aug 3, 2026
@williamhyun

Copy link
Copy Markdown
Member Author

@steveloughran steveloughran left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented.

  1. Error handling is a lot more complex and s3FileIO has historically been weak here; not seen enough errors or layers above have recovered and people haven't noticed/complained about the performance penalties. Factoring it all out into one place lines up for better maintenance and adding handlers for the failure situations which haven't been handled yet.

  2. Curious whether that 4MB block size came from; I think 8 MB is what others use.

  3. It would be really interesting to consider the merits of prefetching or not. Parquet files do NOT want prefetching, they want parallel ranged reads in separate threads. If puffin file readers are using vector read APIs that is what they will want too. Otherwise, reading those files may benefit from prefetching. Avro files do, FWIW.

public boolean exists() {
try {
HttpGet request = new HttpGet(url);
request.setHeader(HttpHeaders.RANGE, "bytes=0-0");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice trick, curious if there's any performance penalty against aws s3 though

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Steve. As far as I'm aware I don't expect a material performance penalty against S3 for this. Since GET and HEAD requests are categorized under the same per-request pricing. A partial GET that returns only one byte should be limited in extra cost. pricing

One thing to note is that GET can incur data-retrieval charges for storage classes such as Standard-IA, One Zone-IA, and Glacier Instant Retrieval, and bytes=0-0 is not satisfiable for a zero-byte object. For normal S3 Standard objects, though, I don’t expect a meaningful performance or cost difference. S3 storage classes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we just use HEAD call here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @xndai, there are a couple things in our model of catalog-vended presigned URLs that limit our use of HEAD requests.

  1. S3's remote signed header is method-bound
  2. The catalog holds the credentials to sign the object and provide it to the client via URL.

Hence the catalog-vended URL is method bound to GET in our read use case and to do a HEAD request on the same object, we would need the catalog to vend a new, separate URL signed for the HEAD request. Effectively doubling sign calls, response payload, and introducing additional spec changes for carrying two URLs.

Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputFile.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
@williamhyun

williamhyun commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thank you @steveloughran for the thoughtful comments,

  1. I'm on board with the direction for the end state error handling model having classifying vs handling separation.
    Given that, I'd like to keep this PR to the current minimal handling and do the RTE mapping as a dedicated follow-up: a generic HTTP-status -> RTE mapper in core to keep it cloud-agnostic, and the S3-specific bits (throttle-403 detection, isThrottlingException, the OpenSSL exception surface) layered in aws where the SDK types are available. Happy to open a tracking issue for that and tag you, would love your input on the design.

  2. The 4MB block size came from matching ContentCache.BUFFER_CHUNK_SIZE, which is the precedent for a read chunk here, so I went with that for consistency. I see that 8MB is a common ecosystem default so I'm not strongly opinionated here. Happy to bump it to 8MB if you'd prefer we align with that convention.

  3. Agreed that parquet wants parallel ranged reads. Currently, HTTPInputStream inherits the default readVectored, which runs them serially, which avoids prefetch but doesn't yet use the separate threads for parallel reading (would be a great follow up for reading performance).

Puffin is more nuanced to my knowledge:

  • Reading deletion vectors bypasses prefetch since the DV DeleteFile has exact offset and size, so reading does one positional range read for exactly the blob. Parallelism comes from the delete worker pool meaning one GET per DV, at the file/task level, rather than grouping multiple ranges into a single stream's vectored read.
  • Reading table stats (NDV) doesn't open the file at all as the statistics are recorded into table metadata JSON at write time.
  • The only path that actually prefetches is bulk blob scans via PuffinReader.readAll. Blobs are written contiguously and read in order, so prefetching coalesces them into fewer GETs. Only caller is the offline rewrite-table-path.

For this PR, I'd propose we keep the prefetch for the sequential consumers (Avro + Puffin bulk scans) and leave readVectored on its serial default for now with a follow up for enabling parallel reading. Noting that S3InputStream also relies on the serial default today, this is consistent with existing behavior.

@steveloughran

Copy link
Copy Markdown
Contributor

The 8 MB came from AWS S3 engineers, so I'd go with that.

As for the other comments, unambitious is more likely to get reviewed by a committer. I am not one, no longer actively doing any software dev, just doing spare time reviewing.

I would at least recommend designing the http response handling such that if it were factored out then it's easy to find. And that 403 response is probably the one to go for. The SDK will retry internally, briefly, but under very heavy load or if something odd is happening like shard rebalancing capacity will drop and suddenly it will become very visible.

If the other stuff is comparable to what's already there (i.e. not a regression, w.r.t. vector reads), then all is good, separate pr work

@williamhyun

Copy link
Copy Markdown
Member Author

Thank you @steveloughran that's a helpful way to scope it!
I've bumped the block size to be 8MB and kept the HTTP response handling changes concentrated on a single easy-to-find seam rather than attempting full error classification here. Would appreciate if you took another look at it!

To summarize what changed:

  • Added a HttpStatusCategory in core to map status code to a vendor-agnostic category such that call sites (sequential/positional reads, getLength(), and exists()) classify through it and then apply their own policy.

  • Coarse mapping for now:

    • 403 -> terminal ForbiddenException on the read/length paths (exists() returns false [1])
    • 404 -> NotFoundException
    • 416 -> empty read
    • 5xx -> retried as transient on the read path (the one-shot length probe and exists() don't retry beyond Apache HttpClient's own default retry)
    • Anything else -> terminal
      Response-body parsing, store-specific throttle detection, and Retry-After/backoff have deliberately been left out to keep the PR minimal.

Thanks for the great context on throttling 403s. Since the pre-signed path bypasses the SDK, its brief internal retries don't apply here; but because the response handler already has the whole response in scope, a store-aware refiner (e.g. in the aws module) can later read the body and reclassify a throttling 403 as transient as follow-up work, without touching these core call sites.

[1] exists() classifies the same way but its policy reduces every non-2xx category to false instead of throwing. A cleaner future policy might let exists() distinguish between "definitely absent" (NOT_FOUND → false) and "couldn't verify" (FORBIDDEN/SERVER_ERROR → throw)

Comment thread core/src/main/java/org/apache/iceberg/io/http/HTTPInputFile.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/io/http/HTTPInputFile.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
Comment thread aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpUrlSupport.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpUrlClient.java
Comment thread core/src/main/java/org/apache/iceberg/io/http/HTTPInputStream.java Outdated
Comment thread core/src/main/java/org/apache/iceberg/io/http/HttpInputStream.java
@singhpk234
singhpk234 requested a review from xndai August 12, 2026 05:36
@williamhyun

Copy link
Copy Markdown
Member Author

Thank you @xndai for the comments,
I have applied the feedback and would appreciate if you could take another look!

Changes made:

  • Centralized all HTTP status handling in a single HttpStatusCategory.classify()
  • Tightened classification for transient and terminal errors.
  • Allow-listed S3 host check before reads
  • Replaced the manual retry loop with Tasks-based exponential backoff

@steveloughran

Copy link
Copy Markdown
Contributor

@williamhyun I like the classify() code design, FWIW. Elegant

public boolean exists() {
try {
HttpGet request = new HttpGet(url);
request.setHeader(HttpHeaders.RANGE, "bytes=0-0");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we just use HEAD call here?

Comment on lines +66 to +70
private static final int MAX_RETRIES = 3;
private static final int MIN_RETRY_WAIT_MS = 100;
private static final int MAX_RETRY_WAIT_MS = 5_000;
private static final int MAX_RETRY_DURATION_MS = 30_000;
private static final double RETRY_SCALE_FACTOR = 2.0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make these configurable?


@Override
public InputFile newInputFile(String path) {
if (HttpUrlClient.isHttpUrl(path)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be pushed up to a BaseFileIO implementation as opposed to duplicating these checks across all implementations.

Comment on lines +92 to +95
* schemes s3a, s3n are also treated as s3 file paths. HTTP(S) URL locations (e.g. a catalog-vended
* pre-signed URL) are read directly over HTTP(S) instead of through the native, credentialed S3
* client; see {@link #newInputFile(String)}. Using this FileIO with other schemes will result in
* {@link org.apache.iceberg.exceptions.ValidationException}.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this clarification is necessary. We're exposing implementation which isn't necessary. The existing documentation states https is supported, so we don't need this addition context.

private static final String ROOT_PREFIX = "s3";
// Default HTTP read chunk size for pre-signed URL reads, tuned for S3; overridable via the
// io.http.read.chunk-size-bytes property.
private static final int HTTP_READ_CHUNK_SIZE_BYTES_DEFAULT = 8 * 1024 * 1024; // 8 MB

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be in S3FileIO since it's a property of the HTTP implementation, not the S3 implementation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is saying s3 prefers 8 MB read ... it might happen ADLS prefers 64 MB ... the generic HTTPInputStream is configurable to what the objectstore provider FileIO prefers, so s3 here is sending this HTTPInputStream it need to read it 8 MB chunks ... rather than HTTPInputstream fixing the http_read_chunk_bytes for everyone.

private SerializableMap<String, String> properties = null;
private MetricsContext metrics = MetricsContext.nullMetrics();
private final AtomicBoolean isResourceClosed = new AtomicBoolean(false);
private transient volatile HttpUrlClient httpUrlClient;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The http client shouldn't be at the S3FileIO, but rather managed by the HTTPInput/Output Class structure.


@Override
public InputFile newInputFile(String path, long length) {
if (HttpUrlClient.isHttpUrl(path)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, this should be delegated to a superclass implementation that can be shared across IO implementations.

@Override
public void initialize(Map<String, String> props) {
this.properties = SerializableMap.copyOf(props);
// reset so the next access rebuilds from the new properties

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be pushed to a super class as well.

Comment on lines +246 to +247
static final Set<String> PRESIGNED_READ_ALLOWED_HOSTS_DEFAULT =
Collections.unmodifiableSet(Sets.newHashSet("amazonaws.com", "amazonaws.com.cn"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary? We shouldn't be prescribing the hosts or managing them from the client. The host is a catalog concern, the client should only process the request.

Comment on lines +876 to +907
public Set<String> presignedReadAllowedHosts() {
return presignedReadAllowedHosts;
}

private static Set<String> parsePresignedReadAllowedHosts(
Map<String, String> properties, String endpoint) {
Set<String> hosts = Sets.newHashSet();
String configured = properties.get(PRESIGNED_READ_ALLOWED_HOSTS);
if (configured == null || configured.trim().isEmpty()) {
hosts.addAll(PRESIGNED_READ_ALLOWED_HOSTS_DEFAULT);
} else {
for (String suffix : Splitter.on(',').trimResults().omitEmptyStrings().split(configured)) {
hosts.add(suffix.toLowerCase(Locale.ROOT));
}
}

// Always trust the configured S3 endpoint host, so custom, VPC, or S3-compatible endpoints
// work without extra configuration.
if (endpoint != null) {
try {
String endpointHost = URI.create(endpoint).getHost();
if (endpointHost != null) {
hosts.add(endpointHost.toLowerCase(Locale.ROOT));
}
} catch (IllegalArgumentException e) {
// An unparseable endpoint is validated elsewhere; ignore it for host allow-listing.
}
}

return hosts;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessary and we should remove it.

Comment on lines +26 to +32
/**
* Restricts catalog-vended pre-signed reads to HTTPS URLs on operator-trusted hosts, before {@link
* S3FileIO} fetches them over HTTP with no S3 credentials. This bounds a compromised or malicious
* catalog to naming files on the expected storage hosts rather than turning the reader into a
* general-purpose fetcher of arbitrary URLs.
*/
class S3PresignedReadValidation {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be protecting from the catalog. This is unnecessary protection because the catalog is the one delegating access.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was worried about this before because I'm terrified of driving a client to a uri that somehow gives access to the client. But I now think we can't actually do anything intelligent here because we don't really now what the endpoints should be.

*
* <p>A length that cannot be determined is reported as {@link #UNKNOWN_LENGTH}.
*/
class HttpHeaderUtil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This util class doesn't strike me as necessary. This is logic that should be part of the HTTPInputFile implementation. I don't think we need a separate class for this functionality?

Generally, utility classes are created when there are multiple independent use cases, but that doesn't seem to be the case here.

Comment on lines +80 to +84
if (length == HttpHeaderUtil.UNKNOWN_LENGTH) {
this.length = fetchContentLength();
}

return length;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic doesn't look quite right. If we see UNKNOWN_LENGTH then we try to fetch the content length, but that can result in an UNKNOWN_LENGTH which would then produce the incorrect length from this call.

If can't establish the length, we should throw.

* status that is actually transient (for example a throttling {@code 403}, which some object stores
* return under heavy load) so that it is retried too.
*/
enum HttpStatusCategory {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we need this class. If this is all isolated to the Http implementation, we don't need an agnostic mapping (we just use the codes from the client).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is doing a classification of the error code to states ... for example which as transient (throttling) / which are persistent (403..) and based on that its taking action in stream .... i wonder why can't we do this in the https://github.com/apache/iceberg/pull/17457/changes#diff-88dbd57947faabb967a76beac0e51b21b52df326a25086cde68f983c43256708R238 ?
will think this more

* When the property is not set, the default passed to {@link #HttpUrlClient(Map, int)} is used,
* letting a {@link org.apache.iceberg.io.FileIO} supply a value tuned for its backing object store.
*/
public class HttpUrlClient implements Serializable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class doesn't make sense to me. We already have a HttpClient interface in the project and this seems to create a separate wrapper interface over a specific implementation.

I don't think we should have this. The client can be initialized and held by the HttpInputFile class directly. This gives the impression that we need a separate standard way of building a client, but it doesn't abstract the underlying implementation and seems like an unnecessary and exposes things in a strange way. It's not actually an HTTP client and only eposes access to InputFile.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants