Skip to content

Commit 01f9788

Browse files
committed
Improve WebClient observations handling of CANCEL signal
Prior to this commit, `WebClient` observations would be recorded as aborted (with tags "outcome":"UNKNOWN", "status":"CLIENT_ERROR") for use cases like this: ``` Flux<String> result = client.get() .uri("/path") .retrieve() .bodyToFlux(String.class) .take(1); ``` This is due to operators like `take` or `next` that consume *some* `onNext` signals and then cancels the subscription before completion. This means the subscriber is only partially interested in the response and we should not count this as a client error. This commit ensures that observations are only recorded as aborted if the response was not published at the time the CANCEL signal was received. The code snippet above will now publish observations with "outcome":"SUCCESS" and "status":"200" tags, for example. Closes gh-30070
1 parent cef597b commit 01f9788

File tree

2 files changed

+53
-4
lines changed

2 files changed

+53
-4
lines changed

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.util.LinkedHashMap;
2727
import java.util.List;
2828
import java.util.Map;
29+
import java.util.concurrent.atomic.AtomicBoolean;
2930
import java.util.function.Consumer;
3031
import java.util.function.Function;
3132
import java.util.function.IntPredicate;
@@ -37,6 +38,7 @@
3738
import org.reactivestreams.Publisher;
3839
import reactor.core.publisher.Flux;
3940
import reactor.core.publisher.Mono;
41+
import reactor.core.publisher.SignalType;
4042
import reactor.util.context.Context;
4143

4244
import org.springframework.core.ParameterizedTypeReference;
@@ -455,13 +457,16 @@ public Mono<ClientResponse> exchange() {
455457
if (this.contextModifier != null) {
456458
responseMono = responseMono.contextWrite(this.contextModifier);
457459
}
460+
final AtomicBoolean responseReceived = new AtomicBoolean();
458461
return responseMono
462+
.doOnNext(response -> responseReceived.set(true))
459463
.doOnError(observationContext::setError)
460-
.doOnCancel(() -> {
461-
observationContext.setAborted(true);
464+
.doFinally(signalType -> {
465+
if (signalType == SignalType.CANCEL && !responseReceived.get()) {
466+
observationContext.setAborted(true);
467+
}
462468
observation.stop();
463469
})
464-
.doOnTerminate(observation::stop)
465470
.contextWrite(context -> context.put(ObservationThreadLocalAccessor.KEY, observation));
466471
});
467472
}

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
import java.time.Duration;
2020
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.Optional;
23+
import java.util.OptionalLong;
2124

2225
import io.micrometer.observation.Observation;
2326
import io.micrometer.observation.ObservationHandler;
@@ -27,10 +30,13 @@
2730
import org.junit.jupiter.api.BeforeEach;
2831
import org.junit.jupiter.api.Test;
2932
import org.mockito.ArgumentCaptor;
33+
import reactor.core.publisher.Flux;
3034
import reactor.core.publisher.Mono;
3135
import reactor.test.StepVerifier;
3236

37+
import org.springframework.http.HttpHeaders;
3338
import org.springframework.http.HttpStatus;
39+
import org.springframework.http.MediaType;
3440

3541
import static org.assertj.core.api.Assertions.assertThat;
3642
import static org.mockito.ArgumentMatchers.any;
@@ -59,7 +65,10 @@ class WebClientObservationTests {
5965
void setup() {
6066
ClientResponse mockResponse = mock();
6167
when(mockResponse.statusCode()).thenReturn(HttpStatus.OK);
68+
when(mockResponse.headers()).thenReturn(new MockClientHeaders());
6269
when(mockResponse.bodyToMono(Void.class)).thenReturn(Mono.empty());
70+
when(mockResponse.bodyToFlux(String.class)).thenReturn(Flux.just("first", "second"));
71+
when(mockResponse.releaseBody()).thenReturn(Mono.empty());
6372
given(this.exchangeFunction.exchange(this.request.capture())).willReturn(Mono.just(mockResponse));
6473
this.builder = WebClient.builder().baseUrl("/base").exchangeFunction(this.exchangeFunction).observationRegistry(this.observationRegistry);
6574
this.observationRegistry.observationConfig().observationHandler(new HeaderInjectingHandler());
@@ -114,6 +123,16 @@ void recordsObservationForCancelledExchange() {
114123
.hasLowCardinalityKeyValue("status", "CLIENT_ERROR");
115124
}
116125

126+
@Test
127+
void recordsObservationForCancelledExchangeDuringResponse() {
128+
StepVerifier.create(this.builder.build().get().uri("/path").retrieve().bodyToFlux(String.class).take(1))
129+
.expectNextCount(1)
130+
.expectComplete()
131+
.verify(Duration.ofSeconds(5));
132+
assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS")
133+
.hasLowCardinalityKeyValue("status", "200");
134+
}
135+
117136
@Test
118137
void setsCurrentObservationInReactorContext() {
119138
ExchangeFilterFunction assertionFilter = new ExchangeFilterFunction() {
@@ -130,7 +149,7 @@ public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction chain
130149
this.builder.filter(assertionFilter).build().get().uri("/resource/{id}", 42)
131150
.retrieve().bodyToMono(Void.class)
132151
.block(Duration.ofSeconds(10));
133-
verifyAndGetRequest();
152+
verifyAndGetRequest();
134153
}
135154

136155
@Test
@@ -170,4 +189,29 @@ public boolean supportsContext(Observation.Context context) {
170189
}
171190
}
172191

192+
static class MockClientHeaders implements ClientResponse.Headers {
193+
194+
private HttpHeaders headers = new HttpHeaders();
195+
196+
@Override
197+
public OptionalLong contentLength() {
198+
return OptionalLong.empty();
199+
}
200+
201+
@Override
202+
public Optional<MediaType> contentType() {
203+
return Optional.empty();
204+
}
205+
206+
@Override
207+
public List<String> header(String headerName) {
208+
return Collections.emptyList();
209+
}
210+
211+
@Override
212+
public HttpHeaders asHttpHeaders() {
213+
return this.headers;
214+
}
215+
}
216+
173217
}

0 commit comments

Comments
 (0)