Skip to content

Commit ebcfcf3

Browse files
authored
Improve the HTTP client (#530)
1 parent c1ba429 commit ebcfcf3

File tree

12 files changed

+284
-220
lines changed

12 files changed

+284
-220
lines changed

Diff for: CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@ All notable changes to this project will be documented in this file. Take a look
1313
#### Shared
1414

1515
* The default `ZIPArchiveOpener` is now using ZIPFoundation instead of Minizip, with improved performances when reading ranges of `stored` ZIP entries.
16+
* Improvements in the HTTP client:
17+
* The `consume` closure of `HTTPClient.stream()` can now return an error to abort the HTTP request.
18+
* `HTTPError` has been refactored for improved type safety and a clearer separation of connection errors versus HTTP errors.
19+
* `DefaultHTTPClient` no longer automatically restarts a failed `HEAD` request as a `GET` to retrieve the response body. If you relied on this behavior, you can implement it using a custom `DefaultHTTPClientDelegate.httpClient(_:recoverRequest:fromError:)`.
1620

1721
### Fixed
1822

23+
#### Shared
24+
25+
* Fixed a crash using `HTTPClient.download()` when the device storage is full.
26+
1927
#### OPDS
2028

2129
* Fixed a data race in the OPDS 1 parser.

Diff for: Sources/Adapters/GCDWebServer/GCDHTTPServer.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,14 @@ public class GCDHTTPServer: HTTPServer, Loggable {
175175
log(.warning, "Resource not found for request \(request)")
176176
completion(
177177
HTTPServerRequest(url: url, href: nil),
178-
HTTPServerResponse(error: .notFound),
178+
HTTPServerResponse(error: .errorResponse(HTTPResponse(
179+
request: HTTPRequest(url: url),
180+
url: url,
181+
status: .notFound,
182+
headers: [:],
183+
mediaType: nil,
184+
body: nil
185+
))),
179186
nil
180187
)
181188
}

Diff for: Sources/LCP/LCPError.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public enum RenewError: Error {
8686
// Incorrect renewal period, your publication could not be renewed.
8787
case invalidRenewalPeriod(maxRenewDate: Date?)
8888
// An unexpected error has occurred on the licensing server.
89-
case unexpectedServerError
89+
case unexpectedServerError(HTTPError)
9090
}
9191

9292
/// Errors while returning a loan.
@@ -96,7 +96,7 @@ public enum ReturnError: Error {
9696
// Your publication has already been returned before or is expired.
9797
case alreadyReturnedOrExpired
9898
// An unexpected error has occurred on the licensing server.
99-
case unexpectedServerError
99+
case unexpectedServerError(HTTPError)
100100
}
101101

102102
/// Errors while parsing the License or Status JSON Documents.

Diff for: Sources/LCP/License/License.swift

+22-12
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,18 @@ extension License: LCPLicense {
221221
return try await httpClient.fetch(HTTPRequest(url: url, method: .put))
222222
.map { $0.body ?? Data() }
223223
.mapError { error -> RenewError in
224-
switch error.kind {
225-
case .badRequest:
226-
return .renewFailed
227-
case .forbidden:
228-
return .invalidRenewalPeriod(maxRenewDate: self.maxRenewDate)
224+
switch error {
225+
case let .errorResponse(response):
226+
switch response.status {
227+
case .badRequest:
228+
return .renewFailed
229+
case .forbidden:
230+
return .invalidRenewalPeriod(maxRenewDate: self.maxRenewDate)
231+
default:
232+
return .unexpectedServerError(error)
233+
}
229234
default:
230-
return .unexpectedServerError
235+
return .unexpectedServerError(error)
231236
}
232237
}
233238
.get()
@@ -260,13 +265,18 @@ extension License: LCPLicense {
260265
do {
261266
let data = try await httpClient.fetch(HTTPRequest(url: url, method: .put))
262267
.mapError { error -> ReturnError in
263-
switch error.kind {
264-
case .badRequest:
265-
return .returnFailed
266-
case .forbidden:
267-
return .alreadyReturnedOrExpired
268+
switch error {
269+
case let .errorResponse(response):
270+
switch response.status {
271+
case .badRequest:
272+
return .returnFailed
273+
case .forbidden:
274+
return .alreadyReturnedOrExpired
275+
default:
276+
return .unexpectedServerError(error)
277+
}
268278
default:
269-
return .unexpectedServerError
279+
return .unexpectedServerError(error)
270280
}
271281
}
272282
.map { $0.body ?? Data() }

Diff for: Sources/LCP/Services/DeviceService.swift

+1-6
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,7 @@ final class DeviceService {
5656
}
5757

5858
let data = await httpClient.fetch(HTTPRequest(url: url, method: .post))
59-
.map { response -> Data? in
60-
guard 100 ..< 400 ~= response.statusCode else {
61-
return nil
62-
}
63-
return response.body
64-
}
59+
.map(\.body)
6560

6661
try await repository.registerDevice(for: license.id)
6762

Diff for: Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift

+80-50
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
182182

183183
public func stream(
184184
request: any HTTPRequestConvertible,
185-
consume: @escaping (Data, Double?) -> Void
185+
consume: @escaping (Data, Double?) -> HTTPResult<Void>
186186
) async -> HTTPResult<HTTPResponse> {
187187
await request.httpRequest()
188188
.asyncFlatMap(willStartRequest)
@@ -321,11 +321,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
321321
typealias Continuation = CheckedContinuation<HTTPResult<HTTPResponse>, Never>
322322
typealias ReceiveResponse = (HTTPResponse) -> Void
323323
typealias ReceiveChallenge = (URLAuthenticationChallenge) async -> URLAuthenticationChallengeResponse
324-
typealias Consume = (Data, Double?) -> Void
325-
326-
enum TaskError: Error {
327-
case byteRangesNotSupported(url: HTTPURL)
328-
}
324+
typealias Consume = (Data, Double?) -> HTTPResult<Void>
329325

330326
private let request: HTTPRequest
331327
fileprivate let task: URLSessionTask
@@ -339,13 +335,20 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
339335
private enum State {
340336
/// Waiting to start the task.
341337
case initializing
338+
342339
/// Waiting for the HTTP response.
343340
case start(continuation: Continuation)
344-
/// We received a success response, the data will be sent to `consume` progressively.
341+
342+
/// We received a success response, the data will be sent to
343+
/// `consume` progressively.
345344
case stream(continuation: Continuation, response: HTTPResponse, readBytes: Int64)
346-
/// We received an error response, the data will be accumulated in `response.body` to make the final
347-
/// `HTTPError`. The body is needed for example when the response is an OPDS Authentication Document.
348-
case failure(continuation: Continuation, kind: HTTPError.Kind, cause: Error?, response: HTTPResponse?)
345+
346+
/// We received an error response, the data will be accumulated in
347+
/// `response.body` if the error is an `HTTPError.errorResponse`, as
348+
/// it could be needed for example when the response is an OPDS
349+
/// Authentication Document.
350+
case failure(continuation: Continuation, error: HTTPError)
351+
349352
/// The request is terminated.
350353
case finished
351354

@@ -357,7 +360,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
357360
return continuation
358361
case let .stream(continuation, _, _):
359362
return continuation
360-
case let .failure(continuation, _, _, _):
363+
case let .failure(continuation, _):
361364
return continuation
362365
}
363366
}
@@ -394,14 +397,15 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
394397
private func finish() {
395398
switch state {
396399
case let .start(continuation):
397-
continuation.resume(returning: .failure(HTTPError(kind: .cancelled)))
400+
continuation.resume(returning: .failure(.cancelled))
398401

399402
case let .stream(continuation, response, _):
400403
continuation.resume(returning: .success(response))
401404

402-
case let .failure(continuation, kind, cause, response):
403-
let error = HTTPError(kind: kind, cause: cause, response: response)
404-
log(.error, "\(request.method) \(request.url) failed with: \(error.localizedDescription)")
405+
case let .failure(continuation, error):
406+
var errorDescription = ""
407+
dump(error, to: &errorDescription)
408+
log(.error, "\(request.method) \(request.url) failed with:\n\(errorDescription)")
405409
continuation.resume(returning: .failure(error))
406410

407411
case .initializing, .finished:
@@ -427,34 +431,22 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
427431

428432
var response = HTTPResponse(request: request, response: urlResponse, url: url)
429433

430-
if let kind = HTTPError.Kind(statusCode: response.statusCode) {
431-
state = .failure(continuation: continuation, kind: kind, cause: nil, response: response)
432-
433-
// It was a HEAD request? We need to query the resource again to get the error body. The body is needed
434-
// for example when the response is an OPDS Authentication Document.
435-
if request.method == .head {
436-
var modifiedRequest = request
437-
modifiedRequest.method = .get
438-
session.dataTask(with: modifiedRequest.urlRequest) { data, _, error in
439-
response.body = data
440-
self.state = .failure(continuation: continuation, kind: kind, cause: error, response: response)
441-
completionHandler(.cancel)
442-
}.resume()
443-
return
444-
}
445-
446-
} else {
447-
guard !request.hasHeader("Range") || response.acceptsByteRanges else {
448-
log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)")
449-
state = .failure(continuation: continuation, kind: .other, cause: TaskError.byteRangesNotSupported(url: url), response: response)
450-
completionHandler(.cancel)
451-
return
452-
}
434+
guard response.status.isSuccess else {
435+
state = .failure(continuation: continuation, error: .errorResponse(response))
436+
completionHandler(.allow)
437+
return
438+
}
453439

454-
state = .stream(continuation: continuation, response: response, readBytes: 0)
455-
receiveResponse(response)
440+
guard !request.hasHeader("Range") || response.acceptsByteRanges else {
441+
log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)")
442+
state = .failure(continuation: continuation, error: .rangeNotSupported)
443+
completionHandler(.cancel)
444+
return
456445
}
457446

447+
state = .stream(continuation: continuation, response: response, readBytes: 0)
448+
receiveResponse(response)
449+
458450
completionHandler(.allow)
459451
}
460452

@@ -469,14 +461,23 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
469461
if let expectedBytes = response.contentLength {
470462
progress = Double(min(readBytes, expectedBytes)) / Double(expectedBytes)
471463
}
472-
consume(data, progress)
473-
state = .stream(continuation: continuation, response: response, readBytes: readBytes)
474-
475-
case .failure(let continuation, let kind, let cause, var response):
476-
var body = response?.body ?? Data()
477-
body.append(data)
478-
response?.body = body
479-
state = .failure(continuation: continuation, kind: kind, cause: cause, response: response)
464+
465+
switch consume(data, progress) {
466+
case .success:
467+
state = .stream(continuation: continuation, response: response, readBytes: readBytes)
468+
case let .failure(error):
469+
state = .failure(continuation: continuation, error: error)
470+
}
471+
472+
case .failure(let continuation, var error):
473+
if case var .errorResponse(response) = error {
474+
var body = response.body ?? Data()
475+
body.append(data)
476+
response.body = body
477+
error = .errorResponse(response)
478+
}
479+
480+
state = .failure(continuation: continuation, error: error)
480481
}
481482
}
482483

@@ -485,7 +486,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
485486
if case .failure = state {
486487
// No-op, we don't want to overwrite the failure state in this case.
487488
} else if let continuation = state.continuation {
488-
state = .failure(continuation: continuation, kind: HTTPError.Kind(error: error), cause: error, response: nil)
489+
state = .failure(continuation: continuation, error: HTTPError(error: error))
489490
} else {
490491
state = .finished
491492
}
@@ -511,6 +512,35 @@ public final class DefaultHTTPClient: HTTPClient, Loggable {
511512
}
512513
}
513514

515+
private extension HTTPError {
516+
/// Maps a native `URLError` to `HTTPError`.
517+
init(error: Error) {
518+
switch error {
519+
case let error as URLError:
520+
switch error.code {
521+
case .httpTooManyRedirects, .redirectToNonExistentLocation:
522+
self = .redirection(error)
523+
case .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, .appTransportSecurityRequiresSecureConnection, .userAuthenticationRequired:
524+
self = .security(error)
525+
case .badServerResponse, .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum:
526+
self = .malformedResponse(error)
527+
case .notConnectedToInternet, .networkConnectionLost:
528+
self = .offline(error)
529+
case .cannotConnectToHost, .cannotFindHost:
530+
self = .unreachable(error)
531+
case .timedOut:
532+
self = .timeout(error)
533+
case .cancelled, .userCancelledAuthentication:
534+
self = .cancelled
535+
default:
536+
self = .other(error)
537+
}
538+
default:
539+
self = .other(error)
540+
}
541+
}
542+
}
543+
514544
private extension HTTPRequest {
515545
var urlRequest: URLRequest {
516546
var request = URLRequest(url: url.url)
@@ -545,7 +575,7 @@ private extension HTTPResponse {
545575
self.init(
546576
request: request,
547577
url: url,
548-
statusCode: response.statusCode,
578+
status: HTTPStatus(rawValue: response.statusCode),
549579
headers: headers,
550580
mediaType: response.mimeType.flatMap { MediaType($0) },
551581
body: body

0 commit comments

Comments
 (0)