Skip to content

Latest commit

 

History

History
232 lines (172 loc) · 9.38 KB

async-await.md

File metadata and controls

232 lines (172 loc) · 9.38 KB

Proposal: Async-await support

Introduction

With the introduction of async/await in Swift 5.5, it is now possible to write asynchronous code without the need for callbacks.

Language support for AsyncSequence also allows for writing functions that return values over time.

We would like to explore how we could offer APIs that make use of these new language features to allow users to run HTTPRequest using these new idioms.

This proposal describes what these APIs could look like and explores some of the potential usability concerns.

Proposed API additions

New HTTPClientRequest type

The proposed new HTTPClientRequest shall be a simple swift structure.

struct HTTPClientRequest {
  /// The requests url.
  var url: String
  
  /// The request's HTTPMethod
  var method: HTTPMethod
  
  /// The request's headers
  var headers: HTTPHeaders

  /// The request's body
  var body: Body?
    
  init(url: String) {
    self.url = url
    self.method = .GET
    self.headers = .init()
    self.body = .none
  }
}

A notable change from the current HTTPClient.Request is that the url is not of type URL. This makes the creation of a request non throwing. Existing issues regarding current API:

The url validation will become part of the normal request validation that occurs when the request is scheduled on the HTTPClient. If the user supplies a request with an invalid url, the http client, will reject the request.

In normal try/catch flows this should not change the control flow:

do {
  var request = HTTPClientRequest(url: "invalidurl")
  try await httpClient.execute(request, deadline: .now() + .seconds(3))
} catch {
  print(error)
}

If the library code throws from the HTTPClientRequest creation or the request invocation the user will, in normal use cases, handle the error in the same catch block.

Request body streaming

The new HTTPClientRequest has a new body type, that is wrapper around an internal enum. This allows us to evolve this type for use-cases that we are not aware of today.

extension HTTPClientRequest {
  public struct Body {
    static func bytes<S: Sequence>(_ sequence: S) -> Body where S.Element == UInt8
  
    static func stream<S: AsyncSequence>(_ sequence: S) -> Body where S.Element == ByteBuffer
  
    static func stream<S: AsyncSequence>(_ sequence: S) -> Body where S.Element == UInt8
  }
}

The main difference to today's Request.Body type is the lack of a StreamWriter for streaming scenarios. The existing StreamWriter offered the user an API to write into (thus the user was in control of when writing happened). The new HTTPClientRequest.Body uses AsyncSequences to stream requests. By iterating over the provided AsyncSequence, the HTTPClient is in control when writes happen, and can ask for more data efficiently.

Using the AsyncSequence from the Swift standard library as our upload stream mechanism dramatically reduces the learning curve for new users.

New HTTPClientResponse type

The HTTPClientResponse looks more similar to the existing HTTPClient.Response type. The biggest difference is again the body property, which is now an AsyncSequence of ByteBuffers instead of a single optional ByteBuffer?. This will make every response on AsyncHTTPClient streaming by default. As with HTTPClientRequest, we dropped the namespacing on HTTPClient to allow easier discovery with autocompletion.

public struct HTTPClientResponse {
  /// the used http version
  public var version: HTTPVersion
  /// the http response status
  public var status: HTTPResponseStatus
  /// the response headers
  public var headers: HTTPHeaders
  /// the response payload as an AsyncSequence
  public var body: Body
}

extension HTTPClientResponse {
  public struct Body: AsyncSequence {
    public typealias Element = ByteBuffer
    public typealias AsyncIterator = Iterator
  
    public struct Iterator: AsyncIteratorProtocol {
      public typealias Element = ByteBuffer
    
      public func next() async throws -> ByteBuffer?
    }
  
    public func makeAsyncIterator() -> Iterator
  }
}

Note: The user must consume the Body stream or drop the HTTPClientResponse, to ensure that the internal HTTPClient connection can move forward. Dropping the HTTPClientResponse would lead to a request cancellation which in turn would lead to a close of an exisiting HTTP/1.1 connection.

At a later point we could add trailers to the HTTPClientResponse as effectful properties:

    public var trailers: HTTPHeaders? { async throws }

However we will need to make sure that the user has consumed the body stream completely before, calling the trailers, because otherwise we might run into a situation from which we can not progress forward:

do {
  var request = HTTPClientRequest(url: "https://swift.org/")
  let response = try await httpClient.execute(request, deadline: .now() + .seconds(3))
  
  var trailers = try await response.trailers // can not move forward since body must be consumed before.
} catch {
  print(error)
}

In such a case we can either throw an error or crash.

New invocation

The new way to invoke a request shall look like this:

extension HTTPClient {
  func execute(_ request: HTTPClientRequest, deadline: NIODeadline) async throws -> HTTPClientResponse
}

Simple usage example:

var request = HTTPClientRequest(url: "https://swift.org")
request.method = .POST
request.headers = [
  "content-type": "text/plain; charset=UTF-8"
  "x-my-fancy-header": "super-awesome"
]
request.body = .bytes("Hello world!".utf8)

var response = try await client.execute(request, deadline: .now() + .seconds(5))

switch response.status {
case .ok:
  let body = try await response.body.collect(maxBytes: 1024 * 1024)
default:
  throw MyUnexpectedHTTPStatusError
}

Stream upload and download example using the new FileHandle api:

let readHandle = FileHandle(forReadingFrom: URL(string: "file:///tmp/readfile")!))
let writeHandle = FileHandle(forWritingTo: URL(string: "file:///tmp/writefile")!))

var request = HTTPClientRequest(url: "https://swift.org/echo")
request.method = .POST
request.headers = [
  "content-type": "text/plain; charset=UTF-8"
  "x-my-fancy-header": "super-awesome"
]
request.body = .stream(readHandle.bytes)

var response = try await client.execute(request, deadline: .now() + .seconds(5))

switch response.status {
case .ok:
  Task {
    var streamIterator = response.body.makeAsyncIterator()
    writeHandle.writeabilityHandler = { handle in
      switch try await streamIterator.next() {
      case .some(let buffer):
        handle.write(buffer.readData(buffer.readableBytes)!)
      case .none:
        handle.close()
      }
    }
  }
default:
  throw MyUnexpectedHTTPStatusError
}
  • Why do we have a deadline in the function signature? Task deadlines are not part of the Swift 5.5 release. However we think that they are an important tool to not overload the http client accidentally. For this reason we will not default them.

  • What happened to the Logger? We will use Task locals to propagate the logger metadata. @slashmo and @ktoso are currently working on this.

  • How does cancellation work? Cancellation works by cancelling the surrounding task:

      ```swift
      let task = Task {
        let response = try await httpClient.execute(request, deadline: .distantFuture)
      }
      
      await Task.sleep(nanosecond: 500 * 1000 * 1000) // wait half a second
      task.cancel() // cancel the task after half a second
      ```
    
  • What happens with all the other configuration options? Currently users can configure a TLSConfiguration on a request. This API doesn't expose this option. We hope to create a three layer model in the future. For this reason, we currently don't want to add per request configuration on the request invocation. More info can be found in the issue: RFC: design suggestion: Make this a "3-tier library"

  • What about convenience APIs? This is our first proposal for an async/await API. We are hesitant at the moment to think about convenience APIs, since we would like to observe actual API usage. Further before we define convenience APIs, we would like to come up with a final API design for the mentioned "3-tier library", to ensure those would be covered with the convenience API as well.