From cc9b0c92ef866bb6df6f7c9d54402f642129d13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 17 Jul 2026 14:52:18 +0200 Subject: [PATCH] Fix streaming failing with rangeNotSupported on servers negotiating compression Fixes #653. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++ .../Toolkit/HTTP/DefaultHTTPClient.swift | 10 ++++ .../Toolkit/HTTP/DefaultHTTPClientTests.swift | 57 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e04b26a7a..8cc67c0676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ All notable changes to this project will be documented in this file. Take a look * `LCPService.init` now requires an explicit `deviceName` parameter. We recommend passing `UIDevice.current.name`. See [the migration guide](docs/Migration%20Guide.md). * `LCPDialogAuthentication` no longer takes a `sender` view controller. It now presents its passphrase dialog through a new `LCPDialogAuthenticationDelegate` that you implement and retain for the lifetime of the authentication. See [the Readium LCP guide](docs/Guides/Readium%20LCP.md) and [the migration guide](docs/Migration%20Guide.md). +### Fixed + +#### Shared + +* [#653](https://github.com/readium/swift-toolkit/issues/653) `DefaultHTTPClient` now sends `Accept-Encoding: identity` with byte range requests. Previously, `URLSession` transparently negotiated a compressed (e.g. gzip) response, for which servers usually cannot serve byte ranges, causing streaming to fail with `HTTPError.rangeNotSupported` even though the server supports ranges. + ### Removed * The deprecated `ReadiumAdapterGCDWebServer` and `ReadiumAdapterLCPSQLite` adapter packages have been removed. diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index fc8bc415c5..2588a95767 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -246,6 +246,16 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { request.userAgent = userAgent } + // `URLSession` transparently negotiates compressed responses (e.g. + // gzip), but servers usually cannot serve byte ranges of a compressed + // representation and may omit the `Accept-Ranges` header in that case. + // Requesting the identity encoding ensures the server advertises and + // serves byte ranges properly. + // See https://github.com/readium/swift-toolkit/issues/653 + if request.hasHeader("Range"), !request.hasHeader("Accept-Encoding") { + request.headers["Accept-Encoding"] = "identity" + } + log(.info, request) let taskDelegate = TaskDelegate( diff --git a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift index b530b26586..c4591657b0 100644 --- a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift @@ -770,6 +770,63 @@ struct DefaultHTTPClientTests { } } + @Test("Range request sends Accept-Encoding: identity to prevent transparent compression") + func rangeRequestSendsIdentityAcceptEncoding() async { + let receivedAcceptEncoding = Capture(nil) + + let client = makeClient { request in + receivedAcceptEncoding.value = request.value(forHTTPHeaderField: "Accept-Encoding") + return .success( + statusCode: 206, + headers: ["Accept-Ranges": "bytes"], + body: Data("partial".utf8) + ) + } + + var httpRequest = HTTPRequest(url: makeURL()) + httpRequest.setRange(0 ..< 7) + _ = await client.fetch(httpRequest) + + #expect(receivedAcceptEncoding.value == "identity") + } + + @Test("Range request preserves a custom Accept-Encoding header") + func rangeRequestPreservesCustomAcceptEncoding() async { + let receivedAcceptEncoding = Capture(nil) + + let client = makeClient { request in + receivedAcceptEncoding.value = request.value(forHTTPHeaderField: "Accept-Encoding") + return .success( + statusCode: 206, + headers: ["Accept-Ranges": "bytes"], + body: Data("partial".utf8) + ) + } + + var httpRequest = HTTPRequest(url: makeURL(), headers: ["Accept-Encoding": "gzip"]) + httpRequest.setRange(0 ..< 7) + _ = await client.fetch(httpRequest) + + #expect(receivedAcceptEncoding.value == "gzip") + } + + @Test("Regular request does not force the Accept-Encoding header") + func regularRequestDoesNotForceAcceptEncoding() async { + let receivedAcceptEncoding = Capture(nil) + + let client = makeClient { request in + receivedAcceptEncoding.value = request.value(forHTTPHeaderField: "Accept-Encoding") + return .success() + } + + _ = await client.fetch(HTTPRequest(url: makeURL())) + + // `URLSession` may transparently add its own `Accept-Encoding` + // (e.g. gzip) at a lower level, so we only check that the client + // did not force the identity encoding. + #expect(receivedAcceptEncoding.value != "identity") + } + @Test("Open-ended setRange omits upper bound in Range header") func openEndedRangeRequest() async { let receivedRange = Capture(nil)