Skip to content

Commit 837072a

Browse files
APPS-3978 (Authenticated)HttpFileSource: Fallback to GET if HEAD is rejected (#149)
* fallback to GET if HEAD is rejected * fix error * pump version --------- Co-authored-by: Blue Hoang <bhoang@dnanexus.com>
1 parent 64aca30 commit 837072a

6 files changed

Lines changed: 126 additions & 16 deletions

File tree

common/RELEASE_NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
* `HttpFileSource` and `AuthenticatedHttpFileSource` now transparently retry with `GET` when the server rejects `HEAD` with `405 Method Not Allowed`, so `exists` / `size` work against servers that selectively register methods.
6+
* `AuthenticatedHttpFileAccessProtocol` no longer attaches Bearer credentials to plain HTTP requests; credentials are only sent over HTTPS.
7+
* `AuthenticatedHttpFileSource.size` returns `-1L` when the server responds `405` to both HEAD and GET probes.
8+
9+
## 0.12.0 (2026-06-11)
10+
511
* Adds `AuthenticatedHttpFileSource` and `AuthenticatedHttpFileAccessProtocol` for HTTP/HTTPS access with Bearer credentials.
612
* Fixes `HttpFileSource.getParent` to return `None` at the root URI instead of a self-referential parent, and fixes `localize` to write cached bytes directly (preserving binary content) and to create missing parent directories.
713

common/src/main/resources/application.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
dxCommon {
2-
version = "0.11.6-SNAPSHOT"
2+
version = "0.12.1-SNAPSHOT"
33
}
44

55
#

common/src/main/scala/dx/util/AuthenticatedHttpFileSource.scala

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,25 @@ case class AuthenticatedHttpFileSource(
5353

5454
private var hasBytes: Boolean = false
5555

56+
private def openConnection(method: String): HttpURLConnection = {
57+
val conn = uri.toURL.openConnection().asInstanceOf[HttpURLConnection]
58+
conn.setRequestMethod(method)
59+
credentials.foreach { c =>
60+
conn.setRequestProperty("Authorization", s"${c.scheme.value} ${c.credentials}")
61+
}
62+
conn
63+
}
64+
5665
private def withConnection[T](method: String = "HEAD")(fn: HttpURLConnection => T): T = {
57-
val url = uri.toURL
5866
var conn: HttpURLConnection = null
5967
try {
60-
conn = url.openConnection().asInstanceOf[HttpURLConnection]
61-
conn.setRequestMethod(method)
62-
credentials.foreach { c =>
63-
conn.setRequestProperty("Authorization", s"${c.scheme.value} ${c.credentials}")
68+
conn = openConnection(method)
69+
// Many servers reject HEAD on a resource that GET would serve (RFC 7231
70+
// §6.5.5). Transparently retry such requests with GET so callers can rely
71+
// on HEAD-style "exists / size" probes regardless of server quirks.
72+
if (method == "HEAD" && conn.getResponseCode == HttpURLConnection.HTTP_BAD_METHOD) {
73+
conn.disconnect()
74+
conn = openConnection("GET")
6475
}
6576
fn(conn)
6677
} finally {
@@ -156,11 +167,20 @@ case class AuthenticatedHttpFileSource(
156167
override lazy val size: Long = {
157168
withConnection() { conn =>
158169
val responseCode = conn.getResponseCode
159-
if (responseCode != HttpURLConnection.HTTP_OK) {
160-
throwOnWrongAuth(responseCode)
161-
throw new Exception(s"Error getting size of URL ${uri}: HTTP ${responseCode}")
170+
responseCode match {
171+
case HttpURLConnection.HTTP_OK =>
172+
conn.getContentLengthLong
173+
case HttpURLConnection.HTTP_BAD_METHOD =>
174+
// Some endpoints reject both HEAD and GET for metadata probes.
175+
// Returning unknown size allows downstream reads to proceed.
176+
logger.trace(
177+
s"Server at ${uri.getHost} returned 405 for size probe; skipping file size check"
178+
)
179+
-1L
180+
case _ =>
181+
throwOnWrongAuth(responseCode)
182+
throw new Exception(s"Error getting size of URL ${uri}: HTTP ${responseCode}")
162183
}
163-
conn.getContentLengthLong
164184
}
165185
}
166186

@@ -268,9 +288,17 @@ case class AuthenticatedHttpFileAccessProtocol(
268288
}
269289

270290
private def credentialsForUri(uri: URI): Option[HttpCredentials] = {
271-
domainBearerTokenForUri(uri).map { token =>
272-
logger.trace(s"Using Bearer token authenticated HTTP for import from: ${uri.getHost}")
273-
HttpCredentials(HttpAuthenticationScheme.Bearer, token)
291+
domainBearerTokenForUri(uri).flatMap { token =>
292+
Option(uri.getScheme) match {
293+
case Some(scheme) if scheme.equalsIgnoreCase(FileUtils.HttpsScheme) =>
294+
logger.trace(s"Using Bearer token authenticated HTTP for import from: ${uri.getHost}")
295+
Some(HttpCredentials(HttpAuthenticationScheme.Bearer, token))
296+
case _ =>
297+
logger.warning(
298+
s"Skipping Bearer token for ${uri.getHost}; credentials are only attached to HTTPS requests"
299+
)
300+
None
301+
}
274302
}
275303
}
276304

common/src/main/scala/dx/util/FileSource.scala

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -565,12 +565,23 @@ case class HttpFileSource(
565565

566566
private var hasBytes: Boolean = false
567567

568+
private def openConnection(method: String): HttpURLConnection = {
569+
val conn = uri.toURL.openConnection().asInstanceOf[HttpURLConnection]
570+
conn.setRequestMethod(method)
571+
conn
572+
}
573+
568574
private def withConnection[T](fn: HttpURLConnection => T): T = {
569-
val url = uri.toURL
570575
var conn: HttpURLConnection = null
571576
try {
572-
conn = url.openConnection().asInstanceOf[HttpURLConnection]
573-
conn.setRequestMethod("HEAD")
577+
conn = openConnection("HEAD")
578+
// Many servers reject HEAD on a resource that GET would serve (RFC 7231
579+
// §6.5.5). Transparently retry such requests with GET so HEAD-style
580+
// "exists / size" probes work regardless of server quirks.
581+
if (conn.getResponseCode == HttpURLConnection.HTTP_BAD_METHOD) {
582+
conn.disconnect()
583+
conn = openConnection("GET")
584+
}
574585
fn(conn)
575586
} finally {
576587
if (conn != null) {

common/src/test/scala/dx/util/AuthenticatedHttpFileSourceTest.scala

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ class AuthenticatedHttpFileSourceTest extends AnyFlatSpec with Matchers with Bef
4545
if (header.contains(s"Bearer $expectedToken")) respond(exchange, 200, okBody)
4646
else respond(exchange, 401, Array.emptyByteArray)
4747
}
48+
// Refuses HEAD with 405 but serves GET normally — simulates servers that
49+
// selectively register methods (Allow: GET, POST), common with some
50+
// application frameworks, CDNs, and dynamic endpoints.
51+
private val handlerHeadNotAllowed: HttpHandler = (exchange: HttpExchange) => {
52+
if (exchange.getRequestMethod == "HEAD") {
53+
exchange.getResponseHeaders.set("Allow", "GET")
54+
exchange.sendResponseHeaders(405, -1)
55+
exchange.close()
56+
} else {
57+
respond(exchange, 200, okBody)
58+
}
59+
}
60+
61+
// Refuses both HEAD and GET with 405.
62+
private val handlerAlways405: HttpHandler = (exchange: HttpExchange) => {
63+
exchange.getResponseHeaders.set("Allow", "POST")
64+
exchange.sendResponseHeaders(405, -1)
65+
exchange.close()
66+
}
67+
4868
// Responds 200 OK without a Content-Length header — simulates servers using
4969
// chunked transfer encoding (e.g. GitHub raw URLs).
5070
private val handlerNoLength: HttpHandler = (exchange: HttpExchange) => {
@@ -71,6 +91,8 @@ class AuthenticatedHttpFileSourceTest extends AnyFlatSpec with Matchers with Bef
7191
server.createContext("/protected", handlerBearerProtected)
7292
server.createContext("/binary", (exchange: HttpExchange) => respond(exchange, 200, binaryBody))
7393
server.createContext("/chunked", handlerNoLength)
94+
server.createContext("/head405", handlerHeadNotAllowed)
95+
server.createContext("/always405", handlerAlways405)
7496
server.setExecutor(null)
7597
server.start()
7698
val port = server.getAddress.getPort
@@ -263,6 +285,33 @@ class AuthenticatedHttpFileSourceTest extends AnyFlatSpec with Matchers with Bef
263285
fs("/chunked/file.txt").size shouldBe -1L
264286
}
265287

288+
it should "transparently retry with GET when the server rejects HEAD with 405" in {
289+
fs("/head405/file.txt").exists shouldBe true
290+
fs("/head405/file.txt").size shouldBe okBody.length.toLong
291+
}
292+
293+
it should "return -1 from size when both HEAD and GET are rejected with 405" in {
294+
fs("/always405/file.txt").size shouldBe -1L
295+
}
296+
297+
// --- protocol credential attachment ----------------------------------
298+
299+
it should "not attach bearer credentials to plain HTTP URLs" in {
300+
val protocol = AuthenticatedHttpFileAccessProtocol(
301+
domainBearerTokens = Map("example.com" -> "secret")
302+
)
303+
val resolved = protocol.resolve("http://example.com/workflow.wdl")
304+
resolved.credentials shouldBe None
305+
}
306+
307+
it should "attach bearer credentials to HTTPS URLs" in {
308+
val protocol = AuthenticatedHttpFileAccessProtocol(
309+
domainBearerTokens = Map("example.com" -> "secret")
310+
)
311+
val resolved = protocol.resolve("https://example.com/workflow.wdl")
312+
resolved.credentials shouldBe Some(bearer("secret"))
313+
}
314+
266315
// --- getParent edge cases --------------------------------------------
267316

268317
it should "return None from getParent at the root URI" in {

common/src/test/scala/dx/util/HttpFileSourceTest.scala

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ class HttpFileSourceTest extends AnyFlatSpec with Matchers with BeforeAndAfterAl
3131
override def beforeAll(): Unit = {
3232
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0)
3333
server.createContext("/binary", (exchange: HttpExchange) => respond(exchange, 200, binaryBody))
34+
// Refuses HEAD with 405 but serves GET normally — mirrors servers that
35+
// selectively register methods (RFC 7231 §6.5.5).
36+
server.createContext(
37+
"/head405",
38+
(exchange: HttpExchange) =>
39+
if (exchange.getRequestMethod == "HEAD") {
40+
exchange.getResponseHeaders.set("Allow", "GET")
41+
exchange.sendResponseHeaders(405, -1)
42+
exchange.close()
43+
} else respond(exchange, 200, binaryBody)
44+
)
3445
server.setExecutor(null)
3546
server.start()
3647
baseUri = URI.create(s"http://127.0.0.1:${server.getAddress.getPort}")
@@ -74,4 +85,9 @@ class HttpFileSourceTest extends AnyFlatSpec with Matchers with BeforeAndAfterAl
7485
FileUtils.deleteRecursive(tempRoot)
7586
}
7687
}
88+
89+
it should "transparently retry with GET when the server rejects HEAD with 405" in {
90+
fs("/head405/file.bin").exists shouldBe true
91+
fs("/head405/file.bin").size shouldBe binaryBody.length.toLong
92+
}
7793
}

0 commit comments

Comments
 (0)