Skip to content

Interceptors Proposal

Michael Rebello edited this page May 26, 2018 · 5 revisions

Author: Michael Rebello (@rebello95)

Last modified: 5/25/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>

Implementation

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 {}
}

Streaming

Swift interceptors will be called in the following way. This approach will allow both unary requests and bi-directional streams to treat interceptors the same way.

  • Each interceptor is called with intercept(...) prior to the request's initial call
  • Upon receiving the first response, the interceptor receives the CallResult, and completes

Note: This does have the downside of disallowing interceptors from knowing about sequential data that is passed up or down the stream. The approach is in-line with other languages' implementations, but we should consider potentially adding support for subscribing to multiple/future data transfers over a single stream.

Request deferral

Interceptors are sometimes used to defer requests, causing them to happen at a time in the near future. With the proposed approach, since this process is synchronous, deferral may be handled by interceptors by simply waiting to call next().

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 - only that they will be called before those with lesser priorities. Thus, "priority" is more of a "preference of call order" in this case.

Alternatives

An alternative approach was considered which would instantiate interceptors and use them asynchronously:

protocol AsyncInterceptor {
  init?(call: InterceptableCall)

  func handleResponse(_ result: CallResult) -> CallResult
}

In this case, each interceptor would be provided the option to be instantiated given the context of an InterceptableCall. Interceptors would then be compactMapped from the channel, and eventually called with didReceiveResponse(...) (instead of being returned the CallResult synchronously after calling next()).

Pros

  • Asynchronous and easier to break up chunks of code within an interceptor

Cons

  • Breaks consistency with all other languages supported by gRPC
  • New instances of each interceptor are instantiated for each API call
  • Leads to more complex filters due to the loss of synchronous workflow
Clone this wiki locally