Skip to content

Commit 330263d

Browse files
authored
Add doc on interceptors (#14)
Signed-off-by: Anuraag Agrawal <[email protected]>
1 parent 3db105e commit 330263d

File tree

1 file changed

+239
-0
lines changed

1 file changed

+239
-0
lines changed

docs/interceptors.md

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Interceptors
2+
3+
Interceptors are similar to the middleware or decorators you may be familiar with from other frameworks:
4+
they're the primary way of extending Connect. They can modify the context, the request, the response,
5+
and any errors. Interceptors are often used to add logging, metrics, tracing, retries, and other functionality.
6+
7+
Take care when writing interceptors! They're powerful, but overly complex interceptors can make debugging difficult.
8+
9+
## Interceptors are protocol implementations
10+
11+
Connect interceptors are protocol implementations with the same signature as an RPC handler, along with a
12+
call_next `Callable` to continue with request processing. This allows writing interceptors in much the same
13+
way as any handler, making sure to call `call_next` when needing to call business logic - or not, if overriding
14+
the response within the interceptor itself.
15+
16+
Connect supports unary RPC and three stream types - because each has a different handler signature, we
17+
provide protocols corresponding to each.
18+
19+
=== "ASGI"
20+
21+
```python
22+
class UnaryInterceptor(Protocol):
23+
24+
async def intercept_unary(
25+
self,
26+
call_next: Callable[[REQ, RequestContext], Awaitable[RES]],
27+
request: REQ,
28+
ctx: RequestContext,
29+
) -> RES: ...
30+
31+
class ClientStreamInterceptor(Protocol):
32+
33+
async def intercept_client_stream(
34+
self,
35+
call_next: Callable[[AsyncIterator[REQ], RequestContext], Awaitable[RES]],
36+
request: AsyncIterator[REQ],
37+
ctx: RequestContext,
38+
) -> RES: ...
39+
40+
class ServerStreamInterceptor(Protocol):
41+
42+
def intercept_server_stream(
43+
self,
44+
call_next: Callable[[REQ, RequestContext], AsyncIterator[RES]],
45+
request: REQ,
46+
ctx: RequestContext,
47+
) -> AsyncIterator[RES]: ...
48+
49+
class BidiStreamInterceptor(Protocol):
50+
51+
def intercept_bidi_stream(
52+
self,
53+
call_next: Callable[[AsyncIterator[REQ], RequestContext], AsyncIterator[RES]],
54+
request: AsyncIterator[REQ],
55+
ctx: RequestContext,
56+
) -> AsyncIterator[RES]: ...
57+
```
58+
59+
=== "WSGI"
60+
61+
```python
62+
class UnaryInterceptorSync(Protocol):
63+
64+
def intercept_unary_sync(
65+
self,
66+
call_next: Callable[[REQ, RequestContext], RES],
67+
request: REQ,
68+
ctx: RequestContext,
69+
) -> RES:
70+
71+
class ClientStreamInterceptorSync(Protocol):
72+
73+
def intercept_client_stream_sync(
74+
self,
75+
call_next: Callable[[Iterator[REQ], RequestContext], RES],
76+
request: Iterator[REQ],
77+
ctx: RequestContext,
78+
) -> RES:
79+
80+
class ServerStreamInterceptorSync(Protocol):
81+
82+
def intercept_server_stream_sync(
83+
self,
84+
call_next: Callable[[REQ, RequestContext], Iterator[RES]],
85+
request: REQ,
86+
ctx: RequestContext,
87+
) -> Iterator[RES]:
88+
89+
class BidiStreamInterceptorSync(Protocol):
90+
91+
def intercept_bidi_stream_sync(
92+
self,
93+
call_next: Callable[[Iterator[REQ], RequestContext], Iterator[RES]],
94+
request: Iterator[REQ],
95+
ctx: RequestContext,
96+
) -> Iterator[RES]:
97+
```
98+
99+
A single class can implement as many of the protocols as needed.
100+
101+
## An example
102+
103+
That's a little abstract, so let's consider an example: we'd like to apply a filter to our greeting
104+
service from the [getting started documentation](./getting-started.md) that says goodbye instead of
105+
hello to certain callers.
106+
107+
=== "ASGI"
108+
109+
```python
110+
class GoodbyeInterceptor:
111+
def __init__(self, users: list[str]):
112+
self._users = users
113+
114+
async def intercept_unary(
115+
self,
116+
call_next: Callable[[GreetRequest, RequestContext], Awaitable[GreetResponse]],
117+
request: GreetRequest,
118+
ctx: RequestContext,
119+
) -> GreetResponse:
120+
if request.name in self._users:
121+
return GreetResponse(greeting=f"Goodbye, {request.name}!")
122+
return await call_next(request, ctx)
123+
```
124+
125+
=== "WSGI"
126+
127+
```python
128+
class GoodbyeInterceptor:
129+
def __init__(self, users: list[str]):
130+
self._users = users
131+
132+
def intercept_unary_sync(
133+
self,
134+
call_next: Callable[[GreetRequest, RequestContext], GreetResponse],
135+
request: GreetRequest,
136+
ctx: RequestContext,
137+
) -> GreetResponse:
138+
if request.name in self._users:
139+
return GreetResponse(greeting=f"Goodbye, {request.name}!")
140+
return call_next(request, ctx)
141+
```
142+
143+
To apply our new interceptor to handlers, we can pass it to the application with `interceptors=`.
144+
145+
=== "ASGI"
146+
147+
```python
148+
app = GreetingServiceASGIApplication(service, interceptors=[GoodbyeInterceptor(["user1", "user2"])])
149+
```
150+
151+
=== "WSGI"
152+
153+
```python
154+
app = GreetingServiceWSGIApplication(service, interceptors=[GoodbyeInterceptor(["user1", "user2"])])
155+
```
156+
157+
Client constructors also accept an `interceptors=` parameter.
158+
159+
=== "Async"
160+
161+
```python
162+
client = GreetingServiceClient("http://localhost:8000", interceptors=[GoodbyeInterceptor(["user1", "user2"])])
163+
```
164+
165+
=== "Sync"
166+
167+
```python
168+
client = GreetingServiceClientSync("http://localhost:8000", interceptors=[GoodbyeInterceptor(["user1", "user2"])])
169+
```
170+
171+
## Metadata interceptors
172+
173+
Because the signature is different for each RPC type, we have an interceptor protocol for each
174+
to be able to intercept RPC messages. However, many interceptors, such as for authentication or
175+
tracing, only need access to headers and not messages. Connect provides a metadata interceptor
176+
protocol that can be implemented to work with any RPC type.
177+
178+
An authentication interceptor checking bearer tokens may look like this:
179+
180+
=== "ASGI"
181+
182+
```python
183+
class AuthInterceptor:
184+
def __init__(self, valid_tokens: list[str]):
185+
self._valid_tokens = valid_tokens
186+
187+
async def on_start(self, ctx: RequestContext):
188+
authorization = ctx.request_headers().get("authorization")
189+
if not authorization or not authorization.startswith("Bearer "):
190+
raise ConnectError(Code.UNAUTHENTICATED)
191+
token = authorization[len("Bearer "):]
192+
if token not in valid_tokens:
193+
raise ConnectError(Code.PERMISSION_DENIED)
194+
```
195+
196+
=== "WSGI"
197+
198+
```python
199+
class AuthInterceptor:
200+
def __init__(self, valid_tokens: list[str]):
201+
self._valid_tokens = valid_tokens
202+
203+
def on_start(self, ctx: RequestContext):
204+
authorization = ctx.request_headers().get("authorization")
205+
if not authorization or not authorization.startswith("Bearer "):
206+
raise ConnectError(Code.UNAUTHENTICATED)
207+
token = authorization[len("Bearer "):]
208+
if token not in valid_tokens:
209+
raise ConnectError(Code.PERMISSION_DENIED)
210+
```
211+
212+
`on_start` can return any value, which is passed to the optional `on_end` method. This can be
213+
used, for example, to record the time of execution for the method.
214+
215+
=== "ASGI"
216+
217+
```python
218+
import time
219+
220+
class TimingInterceptor:
221+
async def on_start(self, ctx: RequestContext) -> float:
222+
return time.perf_counter()
223+
224+
async def on_end(self, token: float, ctx: RequestContext):
225+
print(f"Method took {} seconds.", token - time.perf_counter())
226+
```
227+
228+
=== "WSGI"
229+
230+
```python
231+
import time
232+
233+
class TimingInterceptor:
234+
def on_start(self, ctx: RequestContext):
235+
return time.perf_counter()
236+
237+
def on_end(self, token: float, ctx: RequestContext):
238+
print(f"Method took {} seconds.", token - time.perf_counter())
239+
```

0 commit comments

Comments
 (0)