[Discussion] NIO-based HTTP Client

SSWG HTTP Client Library

Introduction

Number of projects implemented their own HTTP client libraries, like:

This shows that there is a need for generic, multi-purpose, non-blocking, asynchronous HTTP client library built on top of SwiftNIO. SSWG aims to provide a number of packages that could be shared between different projects, and I think proposed HTTP client library would be a good fit for those projects and other use-cases.

Motivation

Having one, community-driver project that can be shared between different projects will hopefully solve the need to implement this functionality in every project from scratch.

Proposed solution

Proposed solution is to have a HTTPClient class, that support a number of often-used methods, as well as an ability to pass a delegate for more precise control over the HTTP data transmission:

class HTTPClient {

    public func get(url: String, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func post(url: String, body: Body? = nil, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func put(url: String, body: Body? = nil, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func delete(url: String, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func execute(request: Request, timeout: Timeout? = nil) -> EventLoopFuture<Response> {
    }

    public func execute<T: HTTPClientResponseDelegate>(request: Request, delegate: T, timeout: Timeout? = nil) -> Task<T.Response> {
    }
}

For the first release we have the following features implemented:

  1. Simple follow-redirects (cookie headers are dropped)
  2. Streaming body download
  3. TLS support
  4. Cookie parsing (but not storage)

Detailed design

Lifecycle

Creating a client is strait-forward:

let client = HTTPClient(eventLoopGroupProvider: .createNew)

This initializer will create new EventLoopGroup, so every client instance will have it's own separate EventLoopGroup. Alternatively, users can supply shared EventLoopGroup:

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let client = HTTPClient(eventLoopGroupProvider: .shared(group))

It is important to shutdown your client instance after it is no longer needed, in order to shutdown internal SwiftNIO machinery:

try client.syncShutdown()

In case EventLoopGroup was provided to the client instance, there is no need to shutdown the client, we expect that lifecycle of that group will be controlled by its owner.

Request

In case helper methods do not provide required functionality (for example, if user needs to set headers, or use specific HTTP method), clients of the library can use HTTPClient.Request:

extension HTTPClient {
    typealias ChunkProvider = (@escaping (IOData) -> EventLoopFuture<Void>) -> EventLoopFuture<Void>

    struct Body {
        var length: Int?
        var provider: HTTPClient.ChunkProvider

        static func byteBuffer(_ buffer: ByteBuffer) -> Body
        static func stream(length: Int? = nil, _ provider: @escaping HTTPClient.ChunkProvider) -> Body
        static func data(_ data: Data) -> Body
        static func string(_ string: String) -> Body
    }

    struct Request {
        public var version: HTTPVersion
        public var method: HTTPMethod
        public var url: URL
        public var headers: HTTPHeaders
        public var body: Body?

        public init(url: String,
                    version: HTTPVersion = HTTPVersion(major: 1, minor: 1),
                    method: HTTPMethod = .GET,
                    headers: HTTPHeaders = HTTPHeaders(),
                    body: Body? = nil) throws {}
    }
}

Example:

var request = try HTTPClient.Request(url: "http://swift.org")
request.headers.add(name: "User-Agent", value: "nio-http-client")

let future = client.execute(request: request)

Response

HTTPClient's methods return an EventLoopFuture<HTTPClient.Reponse. This struct is defined as follows:

extension HTTPClient {
    struct Response {
        public var host: String
        public var status: HTTPResponseStatus
        public var headers: HTTPHeaders
        public var body: ByteBuffer?
    }
}

where HTTPResponseStatus is an enum that describes HTTP codes that could be returned by the server:

client.get(url: "http://swift.org").whenSuccess { response in
    switch response.status {
        case .ok: print("server return 200 OK")
        case .notFound: print("server return 404 Not Found")
        case .internalServerError: print("server return 500 Internal Server Error")
        ...
    }
}

HTTPClientResponseDelegate

In addition to helper/request methods, library also provides the following delegate, which provides greater control over how HTTP response is processed:

public protocol HTTPClientResponseDelegate: class {
    associatedtype Response

    // this method will be called when request body is sent  
    func didTransmitRequestBody(task: HTTPClient.Task<Response>)

    // this method will be called when we receive Head response, with headers and status code
    func didReceiveHead(task: HTTPClient.Task<Response>, _ head: HTTPResponseHead)

    // this method will be called multiple times with chunks of the HTTP response body (if there is a body)
    // if there is a need to handle backpressure, one can return EventLoopFuture, so all subsequent reads will be done after its completion
    func didReceivePart(task: HTTPClient.Task<Response>, _ buffer: ByteBuffer) -> EventLoopFuture<Void>?

    // this method will be called if an error occurs during request processing
    func didReceiveError(task: HTTPClient.Task<Response>, _ error: Error)

    // this will be called when HTTP response is read fully
    func didFinishRequest(task: HTTPClient.Task<Response>) throws -> Response
}

This delegate will be especially helpful when you need to process HTTP response body in a streaming fashion, for example:

class CountingDelegate: HTTPClientResponseDelegate {
    typealias Response = Int

    var count = 0
    
    func didTransmitRequestBody() {
    }

    func didReceiveHead(_ head: HTTPResponseHead) {
    }

    func didReceivePart(_ buffer: ByteBuffer) -> EventLoopFuture<Void>? {
        count += buffer.readableBytes
        return nil
    }

    func didFinishRequest() throws -> Int {
        return count
    }
    
    func didReceiveError(_ error: Error) {
    }
}

let request = try HTTPRequest(url: "https://swift.org")
let delegate = CountingDelegate()

try client.execute(request: request, delegate: delegate).future.whenSuccess { count in
    print(count) // count is of type Int
}

Seeking feedback

Feedback that would really be great is:

  • Streaming API: what do you like, what don't you like?
  • What feature set would be acceptable for 1.0.0?
6 Likes

I absolutely love this whole idea in general and this API design in particular. However, I'm not sure about EventLoopGroupProvider enum and eventLoopGroupProvider initializer argument. It doesn't seem so swifty. I think you could just stick to optionals:

public init(eventLoopGroup: EventLoopGroup? = nil, configuration: Configuration = Configuration()) {
    self.eventLoopGroup = eventLoopGroop ?? MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
    // etc
}

On the other hand, I realize that it introduces some additional logic overhead in parts like syncShutdown etc. In this case optional eventLoopGroup could be convenience initializer which would still wrap group as enum case. I mean, as a developer, I oughtn't care so much about inner library ideology/mechanics, so just passing event loop group is quite understandable step.

I think it's important to stress via the API that if you do not pass EventLoopGroup, one will be created for you, hence the .createNew enum case. That said, having convenience init with explicit EventLoopGroup could be a good idea.

Could this not just be a default initializer rather than an enum?

public init(eventLoopGroup: EventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)) {
   // ...
}

If not - then, rather than requiring try client.syncShutdown(), could we not check if .createNew was the method used and call that during deinit?

How would you then distinguish if it was passed it or not?

No. It's very important not to use ARC to manage scarce resources like file descriptors or threads. ARC works really well for memory because memory has some nice properties: 1) memory can be released from any thread; 2) releasing memory too late/never is usually not a fatal issue; 3) an application usually doesn't notice whether memory has or has not yet been released. If you start using ARC for scarce resources you'll end up in a bad place. Why?

  • you don't know if/when deinit is being called (HTTPClient might be part of some (temporary) reference cycle)
  • you don't know which thread deinit is called on (this can lead to non-deterministic dead locks and event loop stalls)

Some old Objective C docs also have some guidelines for this: "Don’t Use dealloc to Manage Scarce Resources".

7 Likes

Looks really promising. The API looks super nice. Too bad it requires Swift5/NIO2 to try it out. All my projects are still in NIO1 land. Maybe I shall spend a few hours to backport it...

One feature that might be worth thinking about is streaming the request body. It's probably not very common, but might influence the design of the response delegate protocol, so maybe it's worth including in 1.0.0?

1 Like

I couldn't find anywhere(in tickets or these forums) the discussion around http/2.

In order for me to use this package I would need it support http/2.

That being said, Im not sure it qualifies as necessary to tag 1.0.0.

I can wait since I already have my own http2 client built in.

1 Like

Just out of interest: Is there anything that's holding you up from moving everything to NIO1? Just curious in case there's anything on our end that is stopping people from moving.

Good point. swift-nio-http-client should and will definitely support HTTP/2, I added a ticket for it.

1 Like

Mostly because Vapor is still on NIO1 :shushing_face:...

1 Like

/cc @tanner0101 re vapor, nio2 and this http client

streaming request body is an interesting use case, eg uploading a file to s3 or similar

Backporting should not be difficult if you decide to do it. You'll need to fix ByteBuffer methods like writeString -> write(string and rename context argument to ctx in TaskHandler class. After that, if tests pass, it should be working fine

Yes got everything, except ByteToMessageDecoder seems to be new in NIO2, correct?

It is part of NIO1 as well, should be defined in Codecs.swift. What error do you get?

@artemredkin we can take the approach we'v taken with swift-log and have a 0.0 release for 4.x and 1.x for 5.x

This is definitely something we should add. How does something like this look like?

public extension HTTPClient {
    typealias ChunkProvider = (@escaping (IOData) -> EventLoopFuture<Void>) -> EventLoopFuture<Void>

    enum Body {
        case byteBuffer(ByteBuffer)
        case data(Data)
        case string(String)
        case stream(size: Int, ChunkProvider)
    }
}

Example usage would be something like this:

var request = try HTTPClient.Request(url: "http://swift.org")

let allocator = ByteBufferAllocator()
let body: HTTPClient.Body = .stream(8) { writer in
    for _ in 0..<2 {
        var buffer = allocator.buffer(capacity: 4)
        buffer.writeString("1234")
        _ = writer(.byteBuffer(buffer))
    }
    return httpClient.group.next().makeSucceededFuture(())
}

request.body = body
client.execute(request: request)

Good idea, I'll do that.

filed.

Ah! That's a very temporary problem, master is already NIO2 and I hear the Vapor 4 alphas aren't that far out :slight_smile:.