Skip to content

Commit b883089

Browse files
authored
refactor(app/core): a unit test suite for rescue body (#3615)
`linkerd-app-core` includes an error recovery body middleware. this middleware will gracefully catch and report errors encountered when polling an inner body, and via an `R`-typed recovery strategy provided by the caller, will attempt to map the error to a gRPC status code denoting an error. before we upgrade to hyper 1.0 in service of linkerd/linkerd2#8733, we add some test coverage to ensure that we preserve the behavior of this middleware. see: * linkerd/linkerd2#8733 * #3614. for historical context on this tower layer, see: * #222 * #1246 * #1282 --- * refactor(http/retry): outline `ForwardCompatibleBody<B>` in #3559 (4b53081), we introduced a backported `Frame<T>` type, and a `ForwardCompatibleBody<B>` type that allows us to interact with a `http_body::Body` circa 0.4.6 in terms of frame-based interfaces that match those of the 1.0 interface. see linkerd/linkerd2#8733 for more information on upgrading hyper. in #3559, we narrowly added this as an internal submodule of the `linkerd-http-retry` library. these facilities however, would have utility in other places such as `linkerd-app-core`. this commit pulls these compatibility shims out into a `linkerd-http-body-compat` library so that they can be imported and reused elsewhere. Signed-off-by: katelyn martin <[email protected]> * nit(http/body-compat): tidy `combinators` imports Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): hoist `errors::code_header` helper Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): `l5d-*` constants are headers these are header values. `http::HeaderName` has a const fn constructor, so let's use that. Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): grpc constants are headers Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): hoist `l5d-` and `grpc-` constants Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): outline `ResponseBody` middleware we'll add a few tests for this middleware shortly. this commit moves this middleware out into its own submodule. Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): encapsulate `ResponseBody` enum for other body middleware, we hide inner enum variants and their constituent members by using the "inner" pattern. this commit tweaks `ResponseBody` to follow suit, such that it now holds an `Inner`, but does not expose its passthrough and rescue variants to callers. Signed-off-by: katelyn martin <[email protected]> * docs(app/core): document `ResponseBody<R, B>` this adds a small documentation comment describing what this type does. Signed-off-by: katelyn martin <[email protected]> * refactor(app/core): a unit test suite for rescue body this commit introduces a test suite for our error recovery middleware. this body middleware provides a mechanism to "rescue" errors, gracefully mapping an error encountered when polling a gRPC body into e.g. trailers with a gRPC status code. before we upgrade this middleware in service of linkerd/linkerd2#8733, we add some test coverage to ensure that we preserve this middleware. Signed-off-by: katelyn martin <[email protected]> --------- Signed-off-by: katelyn martin <[email protected]>
1 parent 2f848a0 commit b883089

File tree

7 files changed

+374
-163
lines changed

7 files changed

+374
-163
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,12 +1358,14 @@ dependencies = [
13581358
"linkerd-error",
13591359
"linkerd-error-respond",
13601360
"linkerd-exp-backoff",
1361+
"linkerd-http-body-compat",
13611362
"linkerd-http-metrics",
13621363
"linkerd-identity",
13631364
"linkerd-idle-cache",
13641365
"linkerd-io",
13651366
"linkerd-meshtls",
13661367
"linkerd-metrics",
1368+
"linkerd-mock-http-body",
13671369
"linkerd-opencensus",
13681370
"linkerd-opentelemetry",
13691371
"linkerd-proxy-api-resolve",

linkerd/app/core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,6 @@ linkerd-system = { path = "../../system" }
8585
semver = "1"
8686

8787
[dev-dependencies]
88+
linkerd-http-body-compat = { path = "../../http/body-compat" }
89+
linkerd-mock-http-body = { path = "../../mock/http-body" }
8890
quickcheck = { version = "1", default-features = false }

linkerd/app/core/src/errors.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod body;
12
pub mod respond;
23

34
pub use self::respond::{HttpRescue, NewRespond, NewRespondService, SyntheticHttpResponse};
@@ -6,6 +7,16 @@ pub use linkerd_proxy_http::h2::H2Error;
67
pub use linkerd_stack::{FailFastError, LoadShedError};
78
pub use tonic::Code as Grpc;
89

10+
/// Header names and values related to error responses.
11+
pub mod header {
12+
use http::header::{HeaderName, HeaderValue};
13+
pub const L5D_PROXY_CONNECTION: HeaderName = HeaderName::from_static("l5d-proxy-connection");
14+
pub const L5D_PROXY_ERROR: HeaderName = HeaderName::from_static("l5d-proxy-error");
15+
pub(super) const GRPC_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/grpc");
16+
pub(super) const GRPC_MESSAGE: HeaderName = HeaderName::from_static("grpc-message");
17+
pub(super) const GRPC_STATUS: HeaderName = HeaderName::from_static("grpc-status");
18+
}
19+
920
#[derive(Debug, thiserror::Error)]
1021
#[error("connect timed out after {0:?}")]
1122
pub struct ConnectTimeout(pub(crate) std::time::Duration);
@@ -18,3 +29,27 @@ pub fn has_grpc_status(error: &crate::Error, code: tonic::Code) -> bool {
1829
.map(|s| s.code() == code)
1930
.unwrap_or(false)
2031
}
32+
33+
// Copied from tonic, where it's private.
34+
fn code_header(code: tonic::Code) -> http::HeaderValue {
35+
use {http::HeaderValue, tonic::Code};
36+
match code {
37+
Code::Ok => HeaderValue::from_static("0"),
38+
Code::Cancelled => HeaderValue::from_static("1"),
39+
Code::Unknown => HeaderValue::from_static("2"),
40+
Code::InvalidArgument => HeaderValue::from_static("3"),
41+
Code::DeadlineExceeded => HeaderValue::from_static("4"),
42+
Code::NotFound => HeaderValue::from_static("5"),
43+
Code::AlreadyExists => HeaderValue::from_static("6"),
44+
Code::PermissionDenied => HeaderValue::from_static("7"),
45+
Code::ResourceExhausted => HeaderValue::from_static("8"),
46+
Code::FailedPrecondition => HeaderValue::from_static("9"),
47+
Code::Aborted => HeaderValue::from_static("10"),
48+
Code::OutOfRange => HeaderValue::from_static("11"),
49+
Code::Unimplemented => HeaderValue::from_static("12"),
50+
Code::Internal => HeaderValue::from_static("13"),
51+
Code::Unavailable => HeaderValue::from_static("14"),
52+
Code::DataLoss => HeaderValue::from_static("15"),
53+
Code::Unauthenticated => HeaderValue::from_static("16"),
54+
}
55+
}
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
use super::{
2+
header::{GRPC_MESSAGE, GRPC_STATUS},
3+
respond::{HttpRescue, SyntheticHttpResponse},
4+
};
5+
use http::header::HeaderValue;
6+
use linkerd_error::{Error, Result};
7+
use pin_project::pin_project;
8+
use std::{
9+
pin::Pin,
10+
task::{Context, Poll},
11+
};
12+
use tracing::{debug, warn};
13+
14+
/// Returns a "gRPC rescue" body.
15+
///
16+
/// This returns a body that, should the inner `B`-typed body return an error when polling for
17+
/// DATA frames, will "rescue" the stream and return a TRAILERS frame that describes the error.
18+
#[pin_project(project = ResponseBodyProj)]
19+
pub struct ResponseBody<R, B>(#[pin] Inner<R, B>);
20+
21+
#[pin_project(project = InnerProj)]
22+
enum Inner<R, B> {
23+
Passthru(#[pin] B),
24+
GrpcRescue {
25+
#[pin]
26+
inner: B,
27+
trailers: Option<http::HeaderMap>,
28+
rescue: R,
29+
emit_headers: bool,
30+
},
31+
}
32+
33+
// === impl ResponseBody ===
34+
35+
impl<R, B> ResponseBody<R, B> {
36+
/// Returns a body in "passthru" mode.
37+
pub fn passthru(inner: B) -> Self {
38+
Self(Inner::Passthru(inner))
39+
}
40+
41+
/// Returns a "gRPC rescue" body.
42+
pub fn grpc_rescue(inner: B, rescue: R, emit_headers: bool) -> Self {
43+
Self(Inner::GrpcRescue {
44+
inner,
45+
rescue,
46+
emit_headers,
47+
trailers: None,
48+
})
49+
}
50+
}
51+
52+
impl<R, B: Default + linkerd_proxy_http::Body> Default for ResponseBody<R, B> {
53+
fn default() -> Self {
54+
Self(Inner::Passthru(B::default()))
55+
}
56+
}
57+
58+
impl<R, B> linkerd_proxy_http::Body for ResponseBody<R, B>
59+
where
60+
B: linkerd_proxy_http::Body<Error = Error>,
61+
R: HttpRescue<B::Error>,
62+
{
63+
type Data = B::Data;
64+
type Error = B::Error;
65+
66+
fn poll_data(
67+
self: Pin<&mut Self>,
68+
cx: &mut Context<'_>,
69+
) -> Poll<Option<Result<Self::Data, Self::Error>>> {
70+
let ResponseBodyProj(inner) = self.project();
71+
match inner.project() {
72+
InnerProj::Passthru(inner) => inner.poll_data(cx),
73+
InnerProj::GrpcRescue {
74+
inner,
75+
trailers,
76+
rescue,
77+
emit_headers,
78+
} => {
79+
// should not be calling poll_data if we have set trailers derived from an error
80+
assert!(trailers.is_none());
81+
match inner.poll_data(cx) {
82+
Poll::Ready(Some(Err(error))) => {
83+
let SyntheticHttpResponse {
84+
grpc_status,
85+
message,
86+
..
87+
} = rescue.rescue(error)?;
88+
let t = Self::grpc_trailers(grpc_status, &message, *emit_headers);
89+
*trailers = Some(t);
90+
Poll::Ready(None)
91+
}
92+
data => data,
93+
}
94+
}
95+
}
96+
}
97+
98+
#[inline]
99+
fn poll_trailers(
100+
self: Pin<&mut Self>,
101+
cx: &mut Context<'_>,
102+
) -> Poll<Result<Option<http::HeaderMap>, Self::Error>> {
103+
let ResponseBodyProj(inner) = self.project();
104+
match inner.project() {
105+
InnerProj::Passthru(inner) => inner.poll_trailers(cx),
106+
InnerProj::GrpcRescue {
107+
inner, trailers, ..
108+
} => match trailers.take() {
109+
Some(t) => Poll::Ready(Ok(Some(t))),
110+
None => inner.poll_trailers(cx),
111+
},
112+
}
113+
}
114+
115+
#[inline]
116+
fn is_end_stream(&self) -> bool {
117+
let Self(inner) = self;
118+
match inner {
119+
Inner::Passthru(inner) => inner.is_end_stream(),
120+
Inner::GrpcRescue {
121+
inner, trailers, ..
122+
} => trailers.is_none() && inner.is_end_stream(),
123+
}
124+
}
125+
126+
#[inline]
127+
fn size_hint(&self) -> http_body::SizeHint {
128+
let Self(inner) = self;
129+
match inner {
130+
Inner::Passthru(inner) => inner.size_hint(),
131+
Inner::GrpcRescue { inner, .. } => inner.size_hint(),
132+
}
133+
}
134+
}
135+
136+
impl<R, B> ResponseBody<R, B> {
137+
fn grpc_trailers(code: tonic::Code, message: &str, emit_headers: bool) -> http::HeaderMap {
138+
debug!(grpc.status = ?code, "Synthesizing gRPC trailers");
139+
let mut t = http::HeaderMap::new();
140+
t.insert(GRPC_STATUS, super::code_header(code));
141+
if emit_headers {
142+
t.insert(
143+
GRPC_MESSAGE,
144+
HeaderValue::from_str(message).unwrap_or_else(|error| {
145+
warn!(%error, "Failed to encode error header");
146+
HeaderValue::from_static("Unexpected error")
147+
}),
148+
);
149+
}
150+
t
151+
}
152+
}
153+
154+
#[cfg(test)]
155+
mod tests {
156+
use super::*;
157+
use crate::errors::header::{GRPC_MESSAGE, GRPC_STATUS};
158+
use http::HeaderMap;
159+
use linkerd_mock_http_body::MockBody;
160+
161+
struct MockRescue;
162+
impl<E> HttpRescue<E> for MockRescue {
163+
/// Attempts to synthesize a response from the given error.
164+
fn rescue(&self, _: E) -> Result<SyntheticHttpResponse, E> {
165+
let synthetic = SyntheticHttpResponse::internal_error("MockRescue::rescue");
166+
Ok(synthetic)
167+
}
168+
}
169+
170+
#[tokio::test]
171+
async fn rescue_body_recovers_from_error_without_grpc_message() {
172+
let (_guard, _handle) = linkerd_tracing::test::trace_init();
173+
let trailers = {
174+
let mut trls = HeaderMap::with_capacity(1);
175+
let value = HeaderValue::from_static("caboose");
176+
trls.insert("trailer", value);
177+
trls
178+
};
179+
let rescue = {
180+
let inner = MockBody::default()
181+
.then_yield_data(Poll::Ready(Some(Ok("inter".into()))))
182+
.then_yield_data(Poll::Ready(Some(Err("an error midstream".into()))))
183+
.then_yield_data(Poll::Ready(Some(Ok("rupted".into()))))
184+
.then_yield_trailer(Poll::Ready(Ok(Some(trailers))));
185+
let rescue = MockRescue;
186+
let emit_headers = false;
187+
ResponseBody::grpc_rescue(inner, rescue, emit_headers)
188+
};
189+
let (data, Some(trailers)) = body_to_string(rescue).await else {
190+
panic!("trailers should exist");
191+
};
192+
assert_eq!(data, "inter");
193+
assert_eq!(
194+
trailers[GRPC_STATUS],
195+
i32::from(tonic::Code::Internal).to_string()
196+
);
197+
assert_eq!(trailers.get(GRPC_MESSAGE), None);
198+
}
199+
200+
#[tokio::test]
201+
async fn rescue_body_recovers_from_error_emitting_message() {
202+
let (_guard, _handle) = linkerd_tracing::test::trace_init();
203+
let trailers = {
204+
let mut trls = HeaderMap::with_capacity(1);
205+
let value = HeaderValue::from_static("caboose");
206+
trls.insert("trailer", value);
207+
trls
208+
};
209+
let rescue = {
210+
let inner = MockBody::default()
211+
.then_yield_data(Poll::Ready(Some(Ok("inter".into()))))
212+
.then_yield_data(Poll::Ready(Some(Err("an error midstream".into()))))
213+
.then_yield_data(Poll::Ready(Some(Ok("rupted".into()))))
214+
.then_yield_trailer(Poll::Ready(Ok(Some(trailers))));
215+
let rescue = MockRescue;
216+
let emit_headers = true;
217+
ResponseBody::grpc_rescue(inner, rescue, emit_headers)
218+
};
219+
let (data, Some(trailers)) = body_to_string(rescue).await else {
220+
panic!("trailers should exist");
221+
};
222+
assert_eq!(data, "inter");
223+
assert_eq!(
224+
trailers[GRPC_STATUS],
225+
i32::from(tonic::Code::Internal).to_string()
226+
);
227+
assert_eq!(trailers[GRPC_MESSAGE], "MockRescue::rescue");
228+
}
229+
230+
#[tokio::test]
231+
async fn rescue_body_works_for_empty() {
232+
let (_guard, _handle) = linkerd_tracing::test::trace_init();
233+
let rescue = {
234+
let inner = MockBody::default();
235+
let rescue = MockRescue;
236+
let emit_headers = false;
237+
ResponseBody::grpc_rescue(inner, rescue, emit_headers)
238+
};
239+
let (data, trailers) = body_to_string(rescue).await;
240+
assert_eq!(data, "");
241+
assert_eq!(trailers, None);
242+
}
243+
244+
#[tokio::test]
245+
async fn rescue_body_works_for_body_with_data() {
246+
let (_guard, _handle) = linkerd_tracing::test::trace_init();
247+
let rescue = {
248+
let inner = MockBody::default().then_yield_data(Poll::Ready(Some(Ok("unary".into()))));
249+
let rescue = MockRescue;
250+
let emit_headers = false;
251+
ResponseBody::grpc_rescue(inner, rescue, emit_headers)
252+
};
253+
let (data, trailers) = body_to_string(rescue).await;
254+
assert_eq!(data, "unary");
255+
assert_eq!(trailers, None);
256+
}
257+
258+
#[tokio::test]
259+
async fn rescue_body_works_for_body_with_trailers() {
260+
let (_guard, _handle) = linkerd_tracing::test::trace_init();
261+
let trailers = {
262+
let mut trls = HeaderMap::with_capacity(1);
263+
let value = HeaderValue::from_static("caboose");
264+
trls.insert("trailer", value);
265+
trls
266+
};
267+
let rescue = {
268+
let inner = MockBody::default().then_yield_trailer(Poll::Ready(Ok(Some(trailers))));
269+
let rescue = MockRescue;
270+
let emit_headers = false;
271+
ResponseBody::grpc_rescue(inner, rescue, emit_headers)
272+
};
273+
let (data, trailers) = body_to_string(rescue).await;
274+
assert_eq!(data, "");
275+
assert_eq!(trailers.expect("has trailers")["trailer"], "caboose");
276+
}
277+
278+
async fn body_to_string<B>(body: B) -> (String, Option<HeaderMap>)
279+
where
280+
B: http_body::Body + Unpin,
281+
B::Error: std::fmt::Debug,
282+
{
283+
let mut body = linkerd_http_body_compat::ForwardCompatibleBody::new(body);
284+
let mut data = String::new();
285+
let mut trailers = None;
286+
287+
// Continue reading frames from the body until it is finished.
288+
while let Some(frame) = body
289+
.frame()
290+
.await
291+
.transpose()
292+
.expect("reading a frame succeeds")
293+
{
294+
match frame.into_data().map(|mut buf| {
295+
use bytes::Buf;
296+
let bytes = buf.copy_to_bytes(buf.remaining());
297+
String::from_utf8(bytes.to_vec()).unwrap()
298+
}) {
299+
Ok(ref s) => data.push_str(s),
300+
Err(frame) => {
301+
let trls = frame
302+
.into_trailers()
303+
.map_err(drop)
304+
.expect("test frame is either data or trailers");
305+
trailers = Some(trls);
306+
}
307+
}
308+
}
309+
310+
tracing::info!(?data, ?trailers, "finished reading body");
311+
(data, trailers)
312+
}
313+
}

0 commit comments

Comments
 (0)