diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift index febeffa5..7539a562 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift @@ -14,19 +14,56 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import AsyncHTTPClient +import Foundation import NIOHTTP1 extension RegistryClient { /// `RegistryClient` errors. public enum Error: Swift.Error, CustomStringConvertible { - case invalidStatus(url: String, HTTPResponseStatus) + case invalidStatus(url: String, HTTPResponseStatus, reason: String? = nil) /// Description of the errors. public var description: String { switch self { - case .invalidStatus(let u, let response): - return "HTTP request to \(u) failed with response: \(response.description)" + case .invalidStatus(let u, let response, let reason): + return "HTTP request to \(u) failed with response: \(response.description). Reason: \(reason ?? "Unknown")" } } } + + /// The container registry typically returns actionable failure reasons in the response body + /// of the failing HTTP Request. This type models the structure of the error message. + /// Reference: https://distribution.github.io/distribution/spec/api/#errors + internal struct ErrorResponse: Codable { + let errors: [RemoteError] + + internal struct RemoteError: Codable { + let code: String + let message: String + let detail: String? + } + + internal static func fromResponseBody(_ body: HTTPClientResponse.Body) async -> ErrorResponse? { + guard var buffer = try? await body.collect(upTo: Int(1.mib())) else { + return nil + } + guard let bytes = buffer.readBytes(length: buffer.readableBytes) else { + return nil + } + let data = Data(bytes) + guard let jsonError = try? JSONDecoder().decode(ErrorResponse.self, from: data) else { + return nil + } + return jsonError + } + + public var jsonString: String { + let data = try? JSONEncoder().encode(self) + guard let data else { + return "{}" + } + return String(data: data, encoding: .utf8) ?? "{}" + } + } } diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift index e9211d9e..644cfd60 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift @@ -49,7 +49,8 @@ extension RegistryClient { return try await request(components: components, method: .HEAD, headers: headers) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } guard let digest = response.headers.first(name: "Docker-Content-Digest") else { @@ -150,7 +151,8 @@ extension RegistryClient { try await request(components: components, headers: headers) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } // How many bytes to expect diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift index 0135c810..2e7affa0 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift @@ -93,7 +93,8 @@ extension RegistryClient { } } else if response.status != .notFound { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } } @@ -114,7 +115,8 @@ extension RegistryClient { throw ContainerizationError(.exists, message: "Content already exists \(descriptor.digest)") default: let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } // Get the location to upload the blob. @@ -149,7 +151,8 @@ extension RegistryClient { break default: let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } guard descriptor.digest == response.headers.first(name: "Docker-Content-Digest") else { diff --git a/Sources/ContainerizationOCI/Client/RegistryClient.swift b/Sources/ContainerizationOCI/Client/RegistryClient.swift index b0c89343..6c897e23 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient.swift @@ -235,7 +235,8 @@ public final class RegistryClient: ContentClient { try await request(components: components, method: .GET, headers: headers) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } var body = try await response.body.collect(upTo: self.bufferSize) @@ -263,7 +264,8 @@ public final class RegistryClient: ContentClient { try await request(components: components) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } } }