Skip to content

Interceptors Proposal

Michael Rebello edited this page Jun 4, 2018 · 5 revisions

Author: Michael Rebello (@rebello95)

Last modified: 6/4/2018

Issue for discussion: https://github.com/grpc/grpc-swift/issues/235

Introduction

Interceptors can be used to pre- and post-process API requests (both in HTTP and RPC technologies). Pre-processing occurs before a request is communicated to the server, and allows interceptors to read and/or modify the request's configurations - in some cases, even deferring or canceling the request. Post-processing takes place when the roundtrip from the server has completed, and provides interceptors with the ability to read response data and potentially manipulate its metadata before returning it to the initiator.

Various flavors of gRPC currently support interceptors (Java, PHP, and Go, to name a few). iOS has no such support in the Objective-C or Swift flavors. This proposal outlines a design for implementing interceptors in gRPC Swift.

Goals

The following functionality should be implemented as part of interceptor support.

  1. Define an interceptor interface that may be adopted to listen to any API call made by a channel a. Receives request metadata (headers, method name, type) prior to starting the call b. Receives response metadata (headers, trailers, method name, type, raw data, status) after the call completes, before the data is handed back to the initiator
  2. Unified interceptor interface for both unary and bidirectional streaming calls a. Potentially the ability to be called multiple times as blocks of data are pushed/received
  3. Support for canceling requests within an interceptor
  4. Support for deferring requests within an interceptor
  5. Ability to add multiple interceptors to a given channel

Nice-to-haves

  • Ability to specify the "prioritization" of interceptors instead of relying on a LIFO paradigm
  • Support for modifying interceptors on-the-fly in addition to handing them to a channel on start-up

Approach

Typically, interceptors are assigned to a given Channel, which calls them as its consumers send various requests through it. To provide a Channel with interceptors, assignment will be allowed via its initializer(s). More details are listed under the channel integration section.

Most languages take a synchronous approach to gRPC interceptors. When an RPC call is made, interceptors are called in reverse order by the channel. Each interceptor is then given the request, and it eventually calls cancel() or next() on that request, which results in the next interceptor being called. This continues until all interceptors have been called (or the request is canceled), at which point the RPC call is started by the channel.

Client

interceptors = [InterceptorA, InterceptorB]

Request

[Initiator] --> [InterceptorB] --> [InterceptorA] --> [RPC Request] --> [Internet]

Response

(Notice the order in which interceptors are called):

[Internet] --> [RPC Response] --> [InterceptorA] --> [InterceptorB] --> [Initiator]

Interceptor

Within this flow, each interceptor responds to an interface as such:

...
def intercept(client_call):
	<do some stuff with client_call>
	response = interceptor.next() // Synchronously makes API call
	<finish doing some stuff with response>

Implementations

Option 1: Asynchronous

Though other languages do not utilize asynchronous gRPC filters, we could take this approach with Swift, which would allow for some additional functionality. For example:

enum InterceptorAction {
    case next(Metadata)
    case cancel
}

protocol AsyncInterceptor {
    /// Allow the interceptor to be instantiated based on the context of a call. If `nil` is returned, the
    /// interceptor will not be called for future responses of this request.
    init?(method: String, style: CallStyle)

    /// Called before the request starts, allowing the interceptor to modify the metadata prior to starting.
    /// Calling `completion` will either a) continue to the next interceptor until the request starts, or b)
    /// cancel the request (depending on the action passed).
    func onStart(metadata: Metadata, completion: @escaping (InterceptorAction) -> Void)

    /// Called for *every request message sent* (including each message sent over bidirectional streams). The
    /// interceptor must call `completion` when it's done processing the `data`. Notice that it is possible to
    /// manipulate the `data` beforehand.
    func onRequest(request: Data, completion: @escaping (Data) -> Void)

    /// Called for *every response* (including each message received over bidirectional streams). Like
    /// `onRequest`, the interceptor may manipulate the `data` prior to calling `completion`.
    func onResponse(response: Data, completion: @escaping (Data) -> Void)

    /// Called when a status is received from the server. `completion` must be called by the interceptor.
    func onStatus(status: ServerStatus, completion: @escaping (ServerStatus) -> Void)
}

In this case, each interceptor would be provided the option to be instantiated given the context of a request. The channel would then call interceptors based on their order/priority, and continue calling them back as needed throughout the request's lifecycle.

Pros

  • Works very well with bidirectional streaming, as each closure can be called as many times as needed as pieces of data are sent/received
  • Since the interceptor is instantiated, it can maintain its own state as needed (such as storing arbitrary data while waiting for a response)
  • Easier to break up chunks of code within an interceptor

Cons

  • Breaks consistency with other languages supported by gRPC
  • New instances of each interceptor are instantiated for each API call
  • Interceptor consumers can become more complicated since they aren't forced into using a synchronous workflow

Option 2: Synchronous

In order to maintain a consistent approach across languages, Swift should adopt a similar approach to handling interceptors. An interceptor may be any type conforming to a protocol conceptually equivalent to:

protocol Interceptor {
  static func intercept(_ call: InterceptableCall) -> CallResult
}

The InterceptableCall mentioned in the above snippet acts as a wrapper around the next interceptor or the actual API call in this case (very similarly to Java's implementation). It will maintain the following attributes:

  • Next task, either another interceptor or the API call itself (think an enum with associated values)
  • Publicly modifiable metadata that interceptors may consume/edit
  • next() function which starts the next task, and returns a CallResult eventually once the API call is made and returned
  • cancel() function which cancels the call, resulting in no future interceptors being called. All past interceptors will be called with the canceled status

Example interface:

public final class InterceptableCall {
  private enum TaskType {
    case interceptor(Interceptor.Type)
    case completion((InterceptableCall) -> CallResult)
  }

  private let nextTask: TaskType

  public let method: String
  public let style: CallStyle
  public var metadata: Metadata

  public func next() -> CallResult {}

  public func cancel() -> CallResult {}
}

Bidirectional streaming would not work very well with this approach since multiple messages cannot be passed down the chain of next() / cancel() functions. The basic streaming functionality that would be supported by the synchronous approach is calling intercept(...) before the initial request, and returning a result on the first response. Subsequent requests/responses would not be passed to the interceptors.

Pros

  • Consistent with other languages supported by gRPC
  • Forcing consumers to use a synchronous flow can make their interceptors simpler

Cons

  • Does not work well with bidirectional streaming (causing consumers to miss subsequent data sent/received after the first request/response)
  • Forces all functionality of an interceptor to be controlled by a single function, which may be unwieldy for consumers with large interceptors

Request deferral

Interceptors are sometimes used to defer requests, causing them to happen at a time in the near future. With the proposed approaches, deferral may be handled by interceptors by simply waiting to call completion() (asynchronous) or next() (synchronous).

Channel integration

To start up a channel with a given set of interceptors, an argument will be added to Channel's designated initializers. Each instance will maintain a private reference to a [Interceptor.Type] variable.

An additional nicety would be to add addInterceptor(interceptor:priority:) and removeInterceptor(interceptor:) functions. These would allow managers of a given channel to inject and remove interceptors on-the-fly as needed. This functionality is useful to consumers who add multiple interceptors from different codepaths which all share the same channel.

Adding these functions would require interceptors to have a "priority", as the caller is not exposed to the current state of a channel's interceptors. The priority assigned to an interceptor would dictate its order in the call stack. Interceptors with matching priorities are not guaranteed to be called in the same order for every request message - only that they will be called before those with lesser priorities. Thus, "priority" is more of a "preference of call order" in this case.