Skip to content

Commit d451d6a

Browse files
committed
Ensure that client responses are observed when filters fail
Prior to this commit, an error thrown by a `ExchangeFilterFunction` configured on a `WebClient` instance would be recorded as such by the client observation, but the response details would be missing from the observation. All filter functions and the exchange function (performing the HTTP call) would be merged into a single `ExchangeFunction`; this instance was instrumented and osberved. As a result, the instrumentation would only get the error signal returned by the filter function and would not see the HTTP response even if it was received. This means that the recorded observation would not have the relevant information for the HTTP status. This commit ensures that between the configured `ExchangeFilterFunction` and the `ExchangeFunction`, an instrumentation `ExchangeFilterFunction` is inserted. This allows to set the client response to the observation context, even if a later error signal is thrown by a filter function. Note that with this change, an error signal sent by a filter function will be still recorded in the observation. See gh-30059
1 parent 8fca258 commit d451d6a

File tree

3 files changed

+47
-8
lines changed

3 files changed

+47
-8
lines changed

Diff for: spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java

+29-4
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ class DefaultWebClient implements WebClient {
7878

7979
private final ExchangeFunction exchangeFunction;
8080

81+
@Nullable
82+
private final ExchangeFilterFunction filterFunctions;
83+
8184
private final UriBuilderFactory uriBuilderFactory;
8285

8386
@Nullable
@@ -93,19 +96,21 @@ class DefaultWebClient implements WebClient {
9396

9497
private final ObservationRegistry observationRegistry;
9598

99+
@Nullable
96100
private final ClientRequestObservationConvention observationConvention;
97101

98102
private final DefaultWebClientBuilder builder;
99103

100104

101-
DefaultWebClient(ExchangeFunction exchangeFunction, UriBuilderFactory uriBuilderFactory,
105+
DefaultWebClient(ExchangeFunction exchangeFunction, @Nullable ExchangeFilterFunction filterFunctions, UriBuilderFactory uriBuilderFactory,
102106
@Nullable HttpHeaders defaultHeaders, @Nullable MultiValueMap<String, String> defaultCookies,
103107
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
104108
@Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> statusHandlerMap,
105-
ObservationRegistry observationRegistry, ClientRequestObservationConvention observationConvention,
109+
ObservationRegistry observationRegistry, @Nullable ClientRequestObservationConvention observationConvention,
106110
DefaultWebClientBuilder builder) {
107111

108112
this.exchangeFunction = exchangeFunction;
113+
this.filterFunctions = filterFunctions;
109114
this.uriBuilderFactory = uriBuilderFactory;
110115
this.defaultHeaders = defaultHeaders;
111116
this.defaultCookies = defaultCookies;
@@ -438,16 +443,21 @@ public Mono<ClientResponse> exchange() {
438443
observation
439444
.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null))
440445
.start();
446+
ExchangeFilterFunction filterFunction = new ObservationFilterFunction(observationContext);
447+
if (filterFunctions != null) {
448+
filterFunction = filterFunctions.andThen(filterFunction);
449+
}
441450
ClientRequest request = requestBuilder.build();
442451
observationContext.setUriTemplate((String) request.attribute(URI_TEMPLATE_ATTRIBUTE).orElse(null));
443452
observationContext.setRequest(request);
444-
Mono<ClientResponse> responseMono = exchangeFunction.exchange(request)
453+
Mono<ClientResponse> responseMono = filterFunction.apply(exchangeFunction)
454+
.exchange(request)
445455
.checkpoint("Request to " + this.httpMethod.name() + " " + this.uri + " [DefaultWebClient]")
446456
.switchIfEmpty(NO_HTTP_CLIENT_RESPONSE_ERROR);
447457
if (this.contextModifier != null) {
448458
responseMono = responseMono.contextWrite(this.contextModifier);
449459
}
450-
return responseMono.doOnNext(observationContext::setResponse)
460+
return responseMono
451461
.doOnError(observationContext::setError)
452462
.doOnCancel(() -> {
453463
observationContext.setAborted(true);
@@ -718,4 +728,19 @@ public Mono<? extends Throwable> apply(ClientResponse response) {
718728
}
719729
}
720730

731+
private static class ObservationFilterFunction implements ExchangeFilterFunction {
732+
733+
private final ClientRequestObservationContext observationContext;
734+
735+
public ObservationFilterFunction(ClientRequestObservationContext observationContext) {
736+
this.observationContext = observationContext;
737+
}
738+
739+
@Override
740+
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
741+
return next.exchange(request)
742+
.doOnNext(this.observationContext::setResponse);
743+
}
744+
}
745+
721746
}

Diff for: spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java

+5-4
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,17 @@ public WebClient build() {
314314
ExchangeFunctions.create(connectorToUse, initExchangeStrategies()) :
315315
this.exchangeFunction);
316316

317-
ExchangeFunction filteredExchange = (this.filters != null ? this.filters.stream()
317+
ExchangeFilterFunction filterFunctions = (this.filters != null ? this.filters.stream()
318318
.reduce(ExchangeFilterFunction::andThen)
319-
.map(filter -> filter.apply(exchange))
320-
.orElse(exchange) : exchange);
319+
.orElse(null) : null);
321320

322321
HttpHeaders defaultHeaders = copyDefaultHeaders();
323322

324323
MultiValueMap<String, String> defaultCookies = copyDefaultCookies();
325324

326-
return new DefaultWebClient(filteredExchange, initUriBuilderFactory(),
325+
return new DefaultWebClient(exchange,
326+
filterFunctions,
327+
initUriBuilderFactory(),
327328
defaultHeaders,
328329
defaultCookies,
329330
this.defaultRequest,

Diff for: spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientObservationTests.java

+13
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction chain
133133
verifyAndGetRequest();
134134
}
135135

136+
@Test
137+
void recordsObservationWithResponseDetailsWhenFilterFunctionErrors() {
138+
ExchangeFilterFunction errorFunction = (req, next) -> next.exchange(req).then(Mono.error(new IllegalStateException()));
139+
WebClient client = this.builder.filter(errorFunction).build();
140+
Mono<Void> responseMono = client.get().uri("/path").retrieve().bodyToMono(Void.class);
141+
StepVerifier.create(responseMono)
142+
.expectError(IllegalStateException.class)
143+
.verify(Duration.ofSeconds(5));
144+
assertThatHttpObservation()
145+
.hasLowCardinalityKeyValue("exception", "IllegalStateException")
146+
.hasLowCardinalityKeyValue("status", "200");
147+
}
148+
136149
private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatHttpObservation() {
137150
return TestObservationRegistryAssert.assertThat(this.observationRegistry)
138151
.hasObservationWithNameEqualTo("http.client.requests").that();

0 commit comments

Comments
 (0)