Skip to content

download_file: use GET for pre-signed S3 URIs - #940

Closed
pjonsson wants to merge 1 commit into
Open-EO:masterfrom
pjonsson:emulate-head-for-signed-requests
Closed

pjonsson wants to merge 1 commit into
Open-EO:masterfrom
pjonsson:emulate-head-for-signed-requests

Conversation

@pjonsson

@pjonsson pjonsson commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Running HEAD on a pre-signed S3 URI
gives a 403 response because the URI
is signed for GET. Use a GET with
a Range 0-0 request instead of HEAD
to figure out if the server accepts
range requests.

Fixes #939

@pjonsson
pjonsson force-pushed the emulate-head-for-signed-requests branch 3 times, most recently from 3c8f941 to 1724326 Compare September 22, 2026 09:15
Comment thread openeo/rest/_connection.py Outdated
query_params = parse_qs(urlparse(url).query)
# Pre-signed S3 URIs are signed for "GET" and will result in a 403 when running
# HEAD on them, so download them all at once instead.
signed = "X-Amz-Signature" in query_params or "Signature" in query_params

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.

is this based query param sniffing based on a standard? Or is this just an ad-hoc observation?

an alternative could be to try the HEAD and swallow its failure

@soxofaan soxofaan Sep 22, 2026 •

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.

Actually, brought up by my colleague @pvbouwel :
in CDSE we do have a pre-signed URL implementation with X-Amz-Signature headers where HEAD is supported, so this PR would introduce a performance regression and reduce stability

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think it's a good idea in general to swallow failures, it's usually the easy fix in the short-term but with time you run into other problems that would have been discovered/fixable if it wasn't for the masking of failures.

Amazon doesn't support third-party S3 implementations, so I doubt they have made any kind of official standard, but the X-Amz-Signature is from S3 Signature Version 4 (https://docs.aws.amazon.com/AmazonS3/latest/developerguide/bucket-policy-s3-sigv4-conditions.html). CloudFlare mentions the X-Amz-Signature in the examples in their documentation though: https://developers.cloudflare.com/r2/api/s3/presigned-urls/

The Signature is from Version 2 and it's what I get with our boto3 configuration. I'm sure it's somewhere in the AWS SDK documentation, but their documentation is massive so I can't find a link.

Putting in "signature version 4 url example" into the search box at the Amazon documentation puts out this as example though:

https://examplebucket.s3.amazonaws.com/test.txt
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request
&X-Amz-Date=20130524T000000Z
&X-Amz-Expires=86400
&X-Amz-SignedHeaders=host
&X-Amz-Signature=<signature-value>

and "signature version 2 url example" in the search box at Amazon documentation puts out this as example:

GET /photos/puppy.jpg
?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Expires=1141889120&Signature=vjbyPxybdZaNmGa%2ByT272YEAiv4%3D HTTP/1.1
Host: awsexamplebucket1.s3.us-west-1.amazonaws.com
Date: Mon, 26 Mar 2007 19:37:58 +0000

If you're worried that Amazon will suddenly change the existing parameter names in version 2/version 4, I don't think that is a big risk since that will make all the current pre-signed URIs in their system invalid, but there's obviously no guarantee that the next signature version will use the same parameter name as version 2/4. It's also quite possible that version 4 will be the latest and greatest for many years since I assume they had competent people doing the design.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@pvbouwel I'm quite curious, could you please explain a bit more about your server/storage setup? Have you reconfigured Ceph to admit signed URLs even for HEAD requests, or are you running some other S3 server? And why would a non-ranged request reduce stability/performance?

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.

Hey @pjonsson,
We actually run against different storage backends which have different levels of support for features. In order to harmonize it and to allow using features that we are not supported on all of our backends we have a proxy layer. This proxy allows vending temporary credentials and a enforcing IAM policies (even for backends where that functionality is not available. It supports SigV4 signing and pre-signed links but since it itself does the authN/authZ it also allows a special flavour of pre-signed URLs where a user can sign an url with a query parameter X-Proxy-Head-As-Get which would result in a pre-signed URL that can be used for both HEAD and GET requests.

Non-ranged requests reduce stability/performance because they correspond to:

  • longer download times
    • which for example can give problems when a backend node is removed it generally stops accepting new requests and stays for a limited amount of time to round-up ongoing requests.
    • also gives a longer exposure window for intermittent issues
  • bigger downloads
    • This can give problems with network components on route between client and server. These components are out of our control as a service provider (e.g. a transparent proxy on the client side which downloads the file and scans it content could first download the whole file and only then after assuring it safe passes it on to the client for big downloads this could leave the client hanging for to long triggering timeout mechanisms)

@soxofaan soxofaan Sep 23, 2026 •

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.

Also on a more general note: the python client must be back-end agnostic, so we should not bake in any S3 related assumptions about how download links are constructed or handled. We can only work on standards concerning ranged downloading

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@pvbouwel I'm not sure how popular your proxy is among commercial cloud providers, but I'm glad the signed URIs work for your situation.

Are you saying that downloading a small part of a big file is why the stability/performance is improved, or are you making some kind of statistical argument for why downloading the entirety of a big file in chunks is better for stability/performance? That it's faster/better to download 5k instead of 50MB is obvious to me, but my intuition about downloading a 50MB file in 5k chunks isn't that it would be better, the transfer time would roughly be the same if we ignore the size of the extra HTTP headers and various network details like time for establishing new connections, TCP slow start, and so on. (File and chunk size were arbitrary choices to convey my example, I'm aware that a 5k chunk size is probably a bad choice in practice.)

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.

@pjonsson ; I indeed do not want to claim that downloading a file chunked will be faster end-to-end (especially not with the approach of iterating over chunks rather than performing parallel requests). But the intermediate steps last less long and connection that last longer have different ways of being cut due to network constraints outside our control as a service provider. For 2 digit MB files our users rarely encounter issues but for artifacts exceeding a size of 1GB it was not uncommon to get user contacts where they were not able to download the results.

So with the initial PR those users would experience a regression for our deployments that go through our S3proxy because it would see the query parameter and do a single download.

Your rework would work with my S3Proxy and would avoid regression but as Stefaan points out might provide issues with other backends.

So the try catch he mentions is a quick fix if their is high urgency.
A more involved solution could be that you change that initial get to specify the range with the chunk size and then check:

  • where ranges supported?
    • If not then a 200 was returned then you know you would have the full body already downloaded so make sure to store that
    • If yes either a 206 was returned and chunked size corresponds with the advertised full size then artifact was smaller or equal to chunk size so also no additional fetches needed
    • If yes either a 206 was returned but not having full size, continue iteration to get more chunks
    • If yes either 416 fallback like with the range 0:0

That should work regardless of backend and even optimize the flow for artifacts < chunk_size

@soxofaan

soxofaan commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

bit short on time here, so quick summary/brainstorm:

  • doing signature sniffing on the URL is not a good indicator to decide whether to do HEAD or not
  • alternative approaches:
    • try HEAD and if it fails: just assume no range support
    • don't do HEAD at all, but immediately do GET with range headers and see if the response is actually ranged
    • allow user to steer: e.g. make _download_all_at_once or _download_ranged public, or add an argument to download_url to override the auto-detection logic

The try approach is probably the easiest to implement as quick fix.
Directly doing the GET saves on a try and a HTTP request, but probably needs quite a bit of refactoring to get right (and testing of this is messy)
Adding a user facing escape hatch is not ideal UX-wise and feels more like a last-resort solution

@pjonsson
pjonsson force-pushed the emulate-head-for-signed-requests branch 2 times, most recently from e068f2f to 04508b3 Compare September 22, 2026 16:19
Comment thread tests/rest/test_job.py
from_bytes = int(search.group(1))
to_bytes = int(search.group(2))
assert from_bytes < to_bytes
assert from_bytes <= to_bytes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Change isn't directly related to this PR, except that these tests fail because the previous assert is too strict.

Running HEAD on a pre-signed S3 URI
gives a 403 response because the URI
is signed for GET. Use a GET with
a Range 0-0 request instead of HEAD
to figure out if the server accepts
range requests.

Fixes Open-EO#939
@pjonsson
pjonsson force-pushed the emulate-head-for-signed-requests branch from 04508b3 to a17ea61 Compare September 22, 2026 17:03
Comment thread tests/rest/test_job.py
return TIFF_CONTENT[from_bytes : to_bytes + 1]
assert from_bytes <= to_bytes
sliced_content = TIFF_CONTENT[from_bytes : to_bytes + 1]
context.status_code = 206

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Unless I'm misunderstanding something, the mock didn't respond with the right headers/status code for range requests earlier.

@pjonsson

Copy link
Copy Markdown
Contributor Author
  • don't do HEAD at all, but immediately do GET with range headers and see if the response is actually ranged

It's not necessarily what you had in mind, but I think I've implemented this.

@soxofaan soxofaan left a comment

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'm still thinking doing try: head, except: pass (swallow failure) is the easiest quickfix for this problem if you want a quick solution

doing it smarter (without a try) isn't as trivial as it seems I'm afraid
(but still on the table as a longer term solution of course)

# to figure out if the server supports range requests.
# Trying to GET a 0-byte file will give a 416-response, so accept that
# since the download will succeed.
head = self.get(url, headers={"Range": "bytes=0-0"}, expected_status=[200, 206, 416], stream=True)

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'm not sure this doing a GET with 0-range headers is a good idea: this will only work if the server supports range. If the server doesn't then the user will download the same, possibly large, payload twice (once for this head-emulation and once for doing the actual download a couple of lines lower in _download_all_at_once

@pjonsson
pjonsson marked this pull request as draft September 23, 2026 11:09
@pjonsson

pjonsson commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

I'm still thinking doing try: head, except: pass (swallow failure) is the easiest quickfix for this problem if you want a quick solution

Ok, I've marked this as a draft and opened #943 with the quick fix.

I managed to narrow the scope of the changes to the HEAD request further so everything except pre-signed S3 URIs behave exactly the same as before, and pre-signed S3 URIs that get a 403 response from HEAD will try to do the full download. Non-pre-signed URIs and pre-signed URIs running against VITO's proxy will use the same chunked download as before.

doing it smarter (without a try) isn't as trivial as it seems I'm afraid (but still on the table as a longer term solution of course)

Yes, I had a look at the refactoring before updating this yesterday and the refactoring is more invasive than one would hope.

Edit: I also split out the test mock updates to a separate PR (#944) since they are just general improvements in the test machinery and weren't really related to this PR.

@soxofaan

Copy link
Copy Markdown
Member

overruled by #943 I think
(feel free to reopen if not)

@soxofaan soxofaan closed this Sep 24, 2026
@pjonsson
pjonsson deleted the emulate-head-for-signed-requests branch September 24, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to download pre-signed S3 URIs with latest Ceph patch releases

3 participants