diff --git a/.gitignore b/.gitignore
index 70e74aca..83cef5c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@
*.iml
**/.git-versioned-pom.xml
tmp/
+.mcp.json
diff --git a/server/pom.xml b/server/pom.xml
index 2f129b32..ab375258 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -65,6 +65,12 @@
org.springframework.bootspring-boot-starter-tomcat
+
+
+ org.springframework
+ spring-webflux
+ org.springframework.boot
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/Config.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/Config.java
index 758a8089..7fa8bd1b 100644
--- a/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/Config.java
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/configuration/Config.java
@@ -1,5 +1,6 @@
package au.org.aodn.ogcapi.server.core.configuration;
+import au.org.aodn.ogcapi.server.core.http.CancelPropagatingJdkConnector;
import au.org.aodn.ogcapi.server.core.service.das.DasProperties;
import au.org.aodn.ogcapi.server.core.service.dda.DdaProperties;
import au.org.aodn.ogcapi.server.core.service.geonetwork.GNProperties;
@@ -16,9 +17,14 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.client.RestTemplate;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import java.net.http.HttpClient;
@Configuration
@EnableScheduling
@@ -31,6 +37,7 @@
public class Config {
public static final String DAS_REST_TEMPLATE = "dasRestTemplate";
+ public static final String DAS_SSE_WEB_CLIENT = "dasSseWebClient";
@Autowired
ObjectMapper mapper;
@@ -69,15 +76,53 @@ public RestTemplate createDasRestTemplate(DasProperties dasProperties) {
factory.setReadTimeout(dasProperties.readTimeout());
RestTemplate restTemplate = new RestTemplate(factory);
- restTemplate.getInterceptors().add((request, body, execution) -> {
+ restTemplate.getInterceptors().add(dasCredentials(dasProperties));
+ return restTemplate;
+ }
+
+ /**
+ * The DAS client for streamed endpoints (the cloud-optimised size estimate). A WebClient
+ * rather than a RestTemplate because a stream has to be cancellable: stopping the read is
+ * what abandons the estimate, and only a cancel reaches the socket. Two things to know:
+ * 1. The connector is customised so a cancel really does reach the socket, see
+ * CancelPropagatingJdkConnector.
+ * 2. There is no timeout here. DasService caps each frame gap with sseIdleTimeout instead.
+ */
+ @Bean(name = DAS_SSE_WEB_CLIENT, defaultCandidate = false)
+ public WebClient createDasSseWebClient(DasProperties dasProperties, ObjectMapper objectMapper) {
+ HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(dasProperties.connectTimeout())
+ // HttpURLConnection follows redirects on GET; the JDK client follows none by
+ // default, so ask for the equivalent rather than silently changing behaviour.
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+
+ WebClient.Builder builder = WebClient.builder()
+ .clientConnector(new CancelPropagatingJdkConnector(httpClient))
+ .baseUrl(dasProperties.host())
+ // The default codec builds its own ObjectMapper. Pass the application's so the DAS
+ // request body follows the same NON_NULL / JsonNullableModule config as everything else.
+ .codecs(codecs -> codecs.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper)))
+ .defaultHeader("X-API-KEY", dasProperties.secret());
+
+ if (dasProperties.internal() != null) {
+ builder.defaultHeader("x-internal-das-header-secret", dasProperties.internal());
+ }
+ return builder.build();
+ }
+
+ /**
+ * Attaches the DAS credentials to every request.
+ */
+ private ClientHttpRequestInterceptor dasCredentials(DasProperties dasProperties) {
+ return (request, body, execution) -> {
HttpHeaders headers = request.getHeaders();
headers.set("X-API-KEY", dasProperties.secret());
if (dasProperties.internal() != null) {
headers.set("x-internal-das-header-secret", dasProperties.internal());
}
return execution.execute(request, body);
- });
- return restTemplate;
+ };
}
@Bean
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/SseClientGoneException.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/SseClientGoneException.java
new file mode 100644
index 00000000..d0e4bc2b
--- /dev/null
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/SseClientGoneException.java
@@ -0,0 +1,31 @@
+package au.org.aodn.ogcapi.server.core.exception;
+
+import java.io.IOException;
+
+/**
+ * Raised when a write to an SSE client fails because the client has disconnected.
+ */
+public class SseClientGoneException extends IOException {
+
+ public SseClientGoneException(String contextId, Throwable cause) {
+ super("SSE client disconnected for " + contextId, cause);
+ }
+
+ /**
+ * Find this exception in {@code throwable}'s cause chain, or null if it is not there.
+ * A disconnect that unwound an upstream read always reaches the caller nested inside
+ * something else: {@code RestTemplate} wraps it in a {@code ResourceAccessException}, and
+ * the streamed DAS estimate wraps it in an {@code UncheckedIOException}.
+ */
+ public static SseClientGoneException find(Throwable throwable) {
+ for (Throwable current = throwable; current != null; current = current.getCause()) {
+ if (current instanceof SseClientGoneException clientGone) {
+ return clientGone;
+ }
+ if (current.getCause() == current) {
+ break;
+ }
+ }
+ return null;
+ }
+}
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/http/CancelPropagatingJdkConnector.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/http/CancelPropagatingJdkConnector.java
new file mode 100644
index 00000000..663abc5b
--- /dev/null
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/http/CancelPropagatingJdkConnector.java
@@ -0,0 +1,222 @@
+package au.org.aodn.ogcapi.server.core.http;
+
+import org.reactivestreams.Publisher;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.core.io.buffer.DefaultDataBufferFactory;
+import org.springframework.http.HttpCookie;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.client.reactive.AbstractClientHttpRequest;
+import org.springframework.http.client.reactive.AbstractClientHttpResponse;
+import org.springframework.http.client.reactive.ClientHttpConnector;
+import org.springframework.http.client.reactive.ClientHttpRequest;
+import org.springframework.http.client.reactive.ClientHttpResponse;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.LinkedCaseInsensitiveMap;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import reactor.adapter.JdkFlowAdapter;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Flow;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * A copy of Spring's JdkClientHttpConnector without the cache(0) it puts on the response body.
+ * That operator never disconnects from its upstream, so a cancel from downstream never reaches
+ * Flow.Subscription.cancel() and the socket stays open while the server keeps producing a
+ * response nobody will read. Cancelling is the documented way to abort a JDK exchange (see
+ * BodySubscribers.ofPublisher), and the DAS estimate stream needs it: when the browser goes
+ * away, DAS only finds out when its socket closes.
+ * Two things to know:
+ * 1. Response cookies are not adapted. DAS sets none, and this is the one part of the original
+ * the fork drops.
+ * 2. There is no request timeout. HttpRequest.Builder#timeout does not cover the body of a
+ * streamed response (JDK-8258397), so callers use a Reactor timeout instead.
+ * All of this goes away once RestClient supports SSE upstream (spring-framework#35164).
+ */
+public class CancelPropagatingJdkConnector implements ClientHttpConnector {
+
+ private final HttpClient httpClient;
+
+ private final DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance;
+
+ public CancelPropagatingJdkConnector(HttpClient httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ @Override
+ public Mono connect(HttpMethod method, URI uri,
+ Function super ClientHttpRequest, Mono> requestCallback) {
+
+ JdkRequest request = new JdkRequest(method, uri, bufferFactory);
+
+ return requestCallback.apply(request).then(Mono.defer(() -> {
+ HttpRequest nativeRequest = request.getNativeRequest();
+
+ CompletableFuture>>> future =
+ httpClient.sendAsync(nativeRequest, HttpResponse.BodyHandlers.ofPublisher());
+
+ return Mono.fromCompletionStage(future)
+ .map(response -> new JdkResponse(response, bufferFactory));
+ }));
+ }
+
+ /**
+ * The request side, same as Spring's but with no timeout. Copied because Spring's is package
+ * private, so the response fork cannot reuse it.
+ */
+ private static final class JdkRequest extends AbstractClientHttpRequest {
+
+ private final HttpMethod method;
+ private final URI uri;
+ private final DataBufferFactory bufferFactory;
+ private final HttpRequest.Builder builder;
+
+ private JdkRequest(HttpMethod method, URI uri, DataBufferFactory bufferFactory) {
+ this.method = method;
+ this.uri = uri;
+ this.bufferFactory = bufferFactory;
+ this.builder = HttpRequest.newBuilder(uri);
+ }
+
+ @Override
+ public HttpMethod getMethod() {
+ return method;
+ }
+
+ @Override
+ public URI getURI() {
+ return uri;
+ }
+
+ @Override
+ public DataBufferFactory bufferFactory() {
+ return bufferFactory;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getNativeRequest() {
+ return (T) builder.build();
+ }
+
+ @Override
+ protected void applyHeaders() {
+ for (Map.Entry> entry : getHeaders().entrySet()) {
+ if (entry.getKey().equalsIgnoreCase(HttpHeaders.CONTENT_LENGTH)) {
+ // The JDK restricts this header; the body publisher below carries the length.
+ continue;
+ }
+ for (String value : entry.getValue()) {
+ builder.header(entry.getKey(), value);
+ }
+ }
+ if (!getHeaders().containsKey(HttpHeaders.ACCEPT)) {
+ builder.header(HttpHeaders.ACCEPT, "*/*");
+ }
+ }
+
+ @Override
+ protected void applyCookies() {
+ MultiValueMap cookies = getCookies();
+ if (cookies.isEmpty()) {
+ return;
+ }
+ builder.header(HttpHeaders.COOKIE, cookies.values().stream()
+ .flatMap(List::stream)
+ .map(HttpCookie::toString)
+ .collect(Collectors.joining(";")));
+ }
+
+ @Override
+ public Mono writeWith(Publisher extends DataBuffer> body) {
+ return doCommit(() -> {
+ builder.method(method.name(), toBodyPublisher(body));
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono writeAndFlushWith(Publisher extends Publisher extends DataBuffer>> body) {
+ return writeWith(Flux.from(body).flatMap(Function.identity()));
+ }
+
+ @Override
+ public Mono setComplete() {
+ return doCommit(() -> {
+ builder.method(method.name(), HttpRequest.BodyPublishers.noBody());
+ return Mono.empty();
+ });
+ }
+
+ private HttpRequest.BodyPublisher toBodyPublisher(Publisher extends DataBuffer> body) {
+ Publisher byteBuffers = body instanceof Mono ?
+ Mono.from(body).map(JdkRequest::toByteBuffer) :
+ Flux.from(body).map(JdkRequest::toByteBuffer);
+
+ Flow.Publisher flow = JdkFlowAdapter.publisherToFlowPublisher(byteBuffers);
+ long contentLength = getHeaders().getContentLength();
+
+ return contentLength > 0 ?
+ HttpRequest.BodyPublishers.fromPublisher(flow, contentLength) :
+ HttpRequest.BodyPublishers.fromPublisher(flow);
+ }
+
+ private static ByteBuffer toByteBuffer(DataBuffer dataBuffer) {
+ ByteBuffer byteBuffer = ByteBuffer.allocate(dataBuffer.readableByteCount());
+ dataBuffer.toByteBuffer(byteBuffer);
+ return byteBuffer;
+ }
+ }
+
+ /**
+ * The response side. The missing cache(0) on the body is the whole reason this file exists.
+ */
+ private static final class JdkResponse extends AbstractClientHttpResponse {
+
+ private JdkResponse(HttpResponse>> response, DataBufferFactory bufferFactory) {
+ super(HttpStatusCode.valueOf(response.statusCode()),
+ adaptHeaders(response),
+ new LinkedMultiValueMap<>(),
+ adaptBody(response, bufferFactory));
+ }
+
+ private static HttpHeaders adaptHeaders(HttpResponse>> response) {
+ Map> rawHeaders = response.headers().map();
+ Map> map = new LinkedCaseInsensitiveMap<>(rawHeaders.size(), Locale.ROOT);
+ MultiValueMap multiValueMap = CollectionUtils.toMultiValueMap(map);
+ multiValueMap.putAll(rawHeaders);
+ return HttpHeaders.readOnlyHttpHeaders(multiValueMap);
+ }
+
+ private static Flux adaptBody(HttpResponse>> response,
+ DataBufferFactory bufferFactory) {
+
+ Flow.Publisher> body = response.body();
+ if (body == null) {
+ return Flux.empty();
+ }
+
+ // No cache(0) here: a cancel from downstream has to reach the JDK subscription.
+ return JdkFlowAdapter.flowPublisherToFlux(body)
+ .flatMapIterable(Function.identity())
+ .map(bufferFactory::wrap)
+ .doOnDiscard(DataBuffer.class, DataBufferUtils::release);
+ }
+ }
+}
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasProperties.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasProperties.java
index fd0f8f9f..5c7ab0f1 100644
--- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasProperties.java
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasProperties.java
@@ -12,6 +12,7 @@ public record DasProperties(
String secret,
String internal,
@DefaultValue("5s") Duration connectTimeout,
- @DefaultValue("30s") Duration readTimeout
+ @DefaultValue("30s") Duration readTimeout,
+ @DefaultValue("2m") Duration sseIdleTimeout
) {
}
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasService.java
index a9042629..22c58026 100644
--- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasService.java
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasService.java
@@ -3,42 +3,55 @@
import au.org.aodn.ogcapi.server.core.configuration.Config;
import au.org.aodn.ogcapi.server.core.model.DatasetMetadata;
import au.org.aodn.ogcapi.server.core.service.ApplicationInfo;
-import au.org.aodn.ogcapi.server.core.util.SseResponseParser;
+import au.org.aodn.ogcapi.server.core.util.DasSseFrames;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
+import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
+import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
+import reactor.core.publisher.Flux;
+import java.io.IOException;
+import java.io.UncheckedIOException;
import java.util.Collections;
import java.util.HashMap;
-import java.util.List;
+import java.util.Iterator;
import java.util.Map;
+import java.util.stream.Stream;
@Service("DataAccessService")
public class DasService implements ApplicationInfo {
+ private static final ParameterizedTypeReference> SSE_FRAME =
+ new ParameterizedTypeReference<>() {
+ };
+
protected final DasProperties dasProperties;
protected final RestTemplate httpClient;
+ protected final WebClient sseHttpClient;
protected final ObjectMapper objectMapper;
- protected final Map> appInfo;
+ protected final Map> appInfo;
public DasService(
DasProperties dasProperties,
@Qualifier(Config.DAS_REST_TEMPLATE) RestTemplate httpClient,
+ @Qualifier(Config.DAS_SSE_WEB_CLIENT) WebClient sseHttpClient,
ObjectMapper objectMapper) {
this.dasProperties = dasProperties;
this.httpClient = httpClient;
+ this.sseHttpClient = sseHttpClient;
this.objectMapper = objectMapper;
this.appInfo = queryInfo(httpClient, dasProperties.host(), dasProperties.infoPath());
}
/**
- * GET a feature-collection from the DAS, optionally bounded by start/end date. Only the date
- * query params that are non-null are added, so a null value is never passed to URI template
- * expansion (which would throw). Any path variables in {@code path} are supplied via
- * {@code pathVariables}.
+ * GET a feature-collection from DAS, optionally bounded by start/end date. Only non-null dates
+ * are added as query params, because expanding a null URI template variable would throw. Any
+ * path variables in path come from pathVariables.
*/
private ResponseEntity getFeatureCollection(String path, String start, String end, Map pathVariables) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(dasProperties.host() + path);
@@ -90,34 +103,67 @@ public ResponseEntity getMooringDetailsBetweenDates(String startDateTime
}
/**
- * Call the data-access-service cloud-optimised size estimate endpoint.
- * The {@code parameters} map is the same batch-style subset request the
- * download job submits (see {@code SubsetParametersUtils}), so DAS interprets
- * the estimate and the download identically. Returns the estimate JSON so the
- * SSE layer can forward it to the frontend unchanged.
- *
- * DAS streams this endpoint over SSE: it heartbeats while computing and then
- * sends the estimate in a terminal event, so the body is read to completion and
- * unwrapped by {@link SseResponseParser}. Because the stream returns 200 as soon
- * as it opens, a failed estimate arrives as an {@code error} event rather than an
- * error status — the parser turns those back into an exception. Only failures
- * raised before the stream starts (auth, API not ready) are still HTTP errors.
+ * Ask DAS for a cloud-optimised size estimate and return the estimate JSON unchanged, for the
+ * SSE layer to forward on. The parameters map is the same subset request the download job
+ * sends (see SubsetParametersUtils), so DAS treats both alike. Three things to know:
+ * 1. DAS answers over SSE: heartbeats while it computes, then the estimate as a final event.
+ * The stream returns 200 as soon as it opens, so a failed estimate arrives as an error event
+ * that DasSseFrames turns into an exception. Only failures before the stream opens (auth, API
+ * not ready) are HTTP errors, and onStatus rewrites those to hide the DAS host.
+ * 2. The response is not buffered. onHeartbeat runs on this thread for each heartbeat, and
+ * callers write to their own SSE client there, which is the only way to notice that client
+ * has gone. The IOException it throws unwinds this call, and leaving the try-with-resources
+ * cancels the Flux, closing the connection so DAS stops the estimate. That cancel only
+ * reaches the socket because of CancelPropagatingJdkConnector.
+ * 3. sseIdleTimeout is the gap allowed between frames, not a limit on the whole call. A slow
+ * estimate is fine while DAS keeps heartbeating; a silent DAS is given up on.
*/
- public String estimateCloudOptimisedDownloadSize(String uuid, Map parameters) {
+ public String estimateCloudOptimisedDownloadSize(String uuid,
+ Map parameters,
+ DasSseFrames.FrameCallback onHeartbeat) {
- String url = UriComponentsBuilder.fromUriString(dasProperties.host() + "/api/v1/das/data/{uuid}/estimate_size")
- .encode()
- .toUriString();
-
- Map uriVars = new HashMap<>();
- uriVars.put("uuid", uuid);
+ Flux> frames = sseHttpClient.post()
+ .uri("/api/v1/das/data/{uuid}/estimate_size", uuid)
+ .accept(MediaType.TEXT_EVENT_STREAM)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(parameters)
+ .retrieve()
+ .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class)
+ .defaultIfEmpty("")
+ .map(body -> new RuntimeException(describe(response.statusCode(), body))))
+ .bodyToFlux(SSE_FRAME)
+ .timeout(dasProperties.sseIdleTimeout());
+
+ boolean sawFrame = false;
+
+ // Closing the stream cancels the Flux, and that is what closes the connection to DAS.
+ try (Stream> stream = frames.toStream()) {
+ for (Iterator> it = stream.iterator(); it.hasNext(); ) {
+ sawFrame = true;
+ String payload = DasSseFrames.readTerminalFrame(objectMapper, it.next());
+ if (payload != null) {
+ return payload;
+ }
+ onHeartbeat.onFrame();
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
- HttpHeaders headers = new HttpHeaders();
- headers.setAccept(List.of(MediaType.TEXT_EVENT_STREAM));
- headers.setContentType(MediaType.APPLICATION_JSON);
+ throw new RuntimeException(sawFrame ?
+ "data-access-service stream ended without a result or error event" :
+ "Empty response from data-access-service");
+ }
- String body = httpClient.postForObject(url, new HttpEntity<>(parameters, headers), String.class, uriVars);
- return SseResponseParser.extractResultData(objectMapper, body);
+ /**
+ * Describe a failure that arrived before the stream opened. WebClient's own message quotes the
+ * request URL, which would show the DAS host to a user, so only the status and whatever DAS
+ * said are reported.
+ */
+ private static String describe(HttpStatusCode status, String body) {
+ String reason = status instanceof HttpStatus known ? " " + known.getReasonPhrase() : "";
+ String failure = "data-access-service returned " + status.value() + reason;
+ return body.isBlank() ? failure : failure + ": " + body;
}
public ResponseEntity getDatasetMetadata(String datasetId) {
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java
index 13e5de0b..38b628bc 100644
--- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/geoserver/wms/WmsServer.java
@@ -707,7 +707,7 @@ public List fetchCapabilitiesLayersByUrl(String wmsServerUrl) {
.getRootLayer()
.getLayers();
- log.info("Fetched and cached get-capabilities layers {} ", layers);
+ log.info("Fetched and cached {} get-capabilities layers for {}", layers.size(), wmsServerUrl);
return layers;
}
}
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/wfs/WfsErrorHandler.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseErrorHandler.java
similarity index 85%
rename from server/src/main/java/au/org/aodn/ogcapi/server/core/exception/wfs/WfsErrorHandler.java
rename to server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseErrorHandler.java
index 93b1dc53..f6809e41 100644
--- a/server/src/main/java/au/org/aodn/ogcapi/server/core/exception/wfs/WfsErrorHandler.java
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseErrorHandler.java
@@ -1,4 +1,4 @@
-package au.org.aodn.ogcapi.server.core.exception.wfs;
+package au.org.aodn.ogcapi.server.core.service.sse;
import au.org.aodn.ogcapi.server.core.exception.GeoserverFieldsNotFoundException;
import au.org.aodn.ogcapi.server.core.exception.UnauthorizedServerException;
@@ -13,12 +13,16 @@
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+/**
+ * Shared error handling for every SSE stream created by SseStreamHandler. It categorises the
+ * failure, runs the caller's cleanup, tells the client, and completes the emitter.
+ */
@Slf4j
-public class WfsErrorHandler {
+public class SseErrorHandler {
private static final Set handledEmitters = Collections.newSetFromMap(new ConcurrentHashMap<>());
private enum ErrorType {
- WFS_SERVER_ERROR,
+ UPSTREAM_SERVER_ERROR,
NETWORK_ERROR,
VALIDATION_ERROR,
UNAUTHORIZED_SERVER_ERROR,
@@ -45,14 +49,14 @@ public static void handleError(Exception e, String uuid, SseEmitter emitter, Run
}
switch (errorType) {
- case WFS_SERVER_ERROR -> {
- // ERROR level so New Relic can alert on failed downloads and identify
+ case UPSTREAM_SERVER_ERROR -> {
+ // ERROR level so New Relic can alert on failed streams and identify
// the problematic upstream server (the URL is in the exception message).
- log.error("WFS server error during download for UUID {}", uuid, e);
+ log.error("Upstream server error during SSE stream for UUID {}", uuid, e);
emitter.send(SseEmitter.event()
.name(SseEventName.ERROR.getValue())
.data(Map.of(
- "message", "WFS server error",
+ "message", "Upstream server error",
"timestamp", System.currentTimeMillis()
)));
emitter.completeWithError(e);
@@ -75,11 +79,11 @@ public static void handleError(Exception e, String uuid, SseEmitter emitter, Run
}
case UNAUTHORIZED_SERVER_ERROR -> {
- log.warn("Unauthorized wfs server for UUID {}", uuid, e);
+ log.warn("Unauthorized upstream server for UUID {}", uuid, e);
emitter.send(SseEmitter.event()
.name(SseEventName.ERROR.getValue())
.data(Map.of(
- "message", "Unauthorized wfs server",
+ "message", "Unauthorized upstream server",
"timestamp", System.currentTimeMillis()
)));
emitter.completeWithError(e);
@@ -97,7 +101,7 @@ public static void handleError(Exception e, String uuid, SseEmitter emitter, Run
}
case UNKNOWN_ERROR -> {
- log.error("Unknown error during WFS download for UUID {}", uuid, e);
+ log.error("Unknown error during SSE stream for UUID {}", uuid, e);
emitter.send(SseEmitter.event()
.name(SseEventName.ERROR.getValue())
.data(Map.of(
@@ -118,11 +122,11 @@ public static void handleError(Exception e, String uuid, SseEmitter emitter, Run
}
private static ErrorType categorizeError(Exception e) {
- // Upstream WFS server rejected the request (4xx/5xx). Must be checked before
+ // The upstream server rejected the request (4xx/5xx). Must be checked before
// the network heuristics below so a failing server is never mistaken for a
// client disconnect.
if (e instanceof HttpStatusCodeException) {
- return ErrorType.WFS_SERVER_ERROR;
+ return ErrorType.UPSTREAM_SERVER_ERROR;
}
if (e instanceof IOException || e instanceof IllegalStateException || e.getMessage() != null && (
@@ -137,7 +141,7 @@ private static ErrorType categorizeError(Exception e) {
return ErrorType.VALIDATION_ERROR;
}
- // Unauthorized wfs error
+ // Unauthorized upstream server error
if (e instanceof UnauthorizedServerException) {
return ErrorType.UNAUTHORIZED_SERVER_ERROR;
}
diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseSession.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseSession.java
index e2b52d06..8a4cd5ab 100644
--- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseSession.java
+++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/sse/SseSession.java
@@ -1,6 +1,6 @@
package au.org.aodn.ogcapi.server.core.service.sse;
-import au.org.aodn.ogcapi.server.core.exception.wfs.WfsErrorHandler;
+import au.org.aodn.ogcapi.server.core.exception.SseClientGoneException;
import au.org.aodn.ogcapi.server.core.model.enumeration.SseEventName;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@@ -44,6 +44,21 @@ public void send(SseEventName eventName, Object data) throws IOException {
emitter.send(SseEmitter.event().name(eventName.getValue()).data(data));
}
+ /**
+ * Send a keep-alive and report a dead client as SseClientGoneException.
+ * This is how a stream checks its client is still there while waiting on an upstream server,
+ * since TCP says nothing about a peer that has gone until you write to it. Call it from the
+ * thread blocked upstream, so the exception unwinds that read and closes the connection
+ * instead of leaving a server computing a result for nobody.
+ */
+ public void probeClient(Object data) throws SseClientGoneException {
+ try {
+ send(SseEventName.KEEP_ALIVE, data);
+ } catch (IOException e) {
+ throw new SseClientGoneException(contextId, e);
+ }
+ }
+
/**
* Start sending a {@code keep-alive} event every {@code intervalSeconds}. The
* payload is recomputed each tick by {@code payloadSupplier} so callers can
@@ -55,7 +70,10 @@ public void startKeepAlive(long intervalSeconds, Supplier