-
Notifications
You must be signed in to change notification settings - Fork 428
Interceptors Proposal
Author: Michael Rebello (@rebello95)
Last modified: 5/25/2018
Issue for discussion: https://github.com/grpc/grpc-swift/issues/235
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.
The following functionality should be implemented as part of interceptor support.
- 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
- 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
- Support for canceling requests within an interceptor
- Support for deferring requests within an interceptor
- Ability to add multiple interceptors to a given channel
- 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
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.
interceptors = [InterceptorA, InterceptorB]
[Initiator]
--> [InterceptorB]
--> [InterceptorA]
--> [RPC Request]
--> [Internet]
(Notice the order in which interceptors are called):
[Internet]
--> [RPC Response]
--> [InterceptorA]
--> [InterceptorB]
--> [Initiator]
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>
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 aCallResult
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 {}
}
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.
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()
.
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.
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 compactMap
ped 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