diff --git a/README.md b/README.md
index b392497..64b27ef 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ client.verifyApiToken();
### Talking to Servers With and Without the v2 API
-The client works with servers that offer the v2 API, and with servers that do not, such as EventSourcingDB 1.2.0. Creating a client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client remembers the answer: from then on, it uses the v2 API if the server offers it, and the v1 API otherwise. If asking fails, e.g. because the server can not be reached, the first request fails, and the next one asks again.
+The client works with servers that offer the v2 API, and with servers that do not, such as EventSourcingDB 1.2.0. Creating a client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client remembers the answer: from then on, it uses the v2 API if the server offers it, and the v1 API otherwise. If asking fails, e.g. because the server can not be reached, the first request fails, and the next one asks again. If the server answers a path of the v2 API with 404 or 405, e.g. after it was rolled back to a version without it, the client asks again on the next request.
The v2 API reads with the HTTP method `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)), which carries a body, as `POST` does, but changes nothing. If a proxy, a load balancer, or a firewall sits in front of the database, it has to let `QUERY` through. The client does not fall back to `POST`.
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java
index 676aaaa..43293fe 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java
@@ -5,6 +5,7 @@
import java.time.Duration;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Stream;
import org.jspecify.annotations.Nullable;
@@ -18,8 +19,9 @@
*
The client talks to every supported version of EventSourcingDB, whether it offers the v2 API or not. Creating a
* client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client
* uses the v2 API from then on if it does, and the v1 API otherwise. If asking fails, e.g. because the server can not
- * be reached, that first request fails, and the next request asks again. The v2 API reads with the HTTP method
- * {@code QUERY}, so a proxy in front of the database has to let it through.
+ * be reached, that first request fails, and the next request asks again. If the server answers a path of the v2 API
+ * with 404 or 405, e.g. after it was rolled back to a version without it, the next request asks again, too. The v2 API
+ * reads with the HTTP method {@code QUERY}, so a proxy in front of the database has to let it through.
*
*
Every method that sends a request throws a {@link DbApiException} if the server reports an error, e.g. a
* precondition that does not hold, and an {@link InvalidServerHeaderException} if the server is not an
@@ -104,10 +106,27 @@ Transport chosenTransport() {
}
// Learns which API the server offers, which only the first request of a
- // client asks the server for.
- private Transport transport(String action) {
+ // client asks the server for, and sends the request through it. How the
+ // request fails may tell that the server does not offer that API any
+ // longer.
+ private T send(String action, Function request) {
Http.throwIfInterrupted(action);
- return TransportChooser.await(action, transports.transport());
+ var choice = TransportChooser.await(action, transports.transport());
+
+ try {
+ return request.apply(choice.transport());
+ } catch (RuntimeException ex) {
+ transports.forgetIfTheV2ApiIsMissing(choice, ex);
+ throw ex;
+ }
+ }
+
+ // Sends a request that returns nothing, as send does.
+ private void run(String action, Consumer request) {
+ send(action, transport -> {
+ request.accept(transport);
+ return null;
+ });
}
private Stream stream(String action, Function> specs) {
@@ -121,7 +140,7 @@ private Stream stream(String action, Function> s
* @throws EventSourcingDbException if the instance is not reachable, or does not confirm the ping
*/
public void ping() {
- transport("ping").ping();
+ run("ping", Transport::ping);
}
/**
@@ -131,7 +150,7 @@ public void ping() {
* @throws EventSourcingDbException if the instance does not confirm the API token
*/
public void verifyApiToken() {
- transport("verify API token").verifyApiToken();
+ run("verify API token", Transport::verifyApiToken);
}
/**
@@ -185,7 +204,7 @@ public List writeEvents(List events, List p
}
}
- return transport("write events").writeEvents(body);
+ return send("write events", transport -> transport.writeEvents(body));
}
private static Map toRequest(Precondition precondition) {
@@ -362,7 +381,7 @@ public Stream readEventTypes() {
* @throws DbApiException with the code {@link ErrorCode#EVENT_TYPE_UNKNOWN} if the event type does not exist
*/
public EventType readEventType(String eventType) {
- return transport("read event type").readEventType(eventType);
+ return send("read event type", transport -> transport.readEventType(eventType));
}
/**
@@ -378,7 +397,7 @@ public EventType readEventType(String eventType) {
*/
public void registerEventSchema(String eventType, Map schema) {
var schemaNode = Json.MAPPER.valueToTree(schema);
- transport("register event schema").registerEventSchema(eventType, schemaNode);
+ run("register event schema", transport -> transport.registerEventSchema(eventType, schemaNode));
}
/**
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java
index ae90946..7b98217 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Http.java
@@ -73,7 +73,7 @@ private static HttpResponse check(
String action, HttpResponse response, Duration stallTimeout, CodeMapping codes) {
if (!isFromEventSourcingDb(response)) {
close(response.body());
- throw new InvalidServerHeaderException();
+ throw new InvalidServerHeaderException(response.statusCode());
}
if (response.statusCode() != 200) {
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderException.java
index 7be8291..8b9c0df 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderException.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/InvalidServerHeaderException.java
@@ -8,7 +8,17 @@
public final class InvalidServerHeaderException extends EventSourcingDbException {
private static final long serialVersionUID = 1L;
- InvalidServerHeaderException() {
+ /** The HTTP status code the server answered with. */
+ private final int status;
+
+ InvalidServerHeaderException(int status) {
super("server must be EventSourcingDB");
+ this.status = status;
+ }
+
+ // Lets the client tell from the status code whether the server offers the
+ // v2 API, which a 404 or a 405 tells whatever server sent it.
+ int status() {
+ return status;
}
}
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java
index 6741201..39e34af 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/LineStream.java
@@ -121,7 +121,8 @@ private void end(BufferedReader reader) {
}
// Learns which API the server offers, and sends the request of the
- // stream through it.
+ // stream through it. How the server answers the request may tell that it
+ // does not offer that API any longer.
private Opened open() {
throwIfClosed();
Http.throwIfInterrupted(action);
@@ -133,12 +134,18 @@ private Opened open() {
InputStream body;
try {
throwIfClosed();
- spec = specs.apply(TransportChooser.await(action, transport));
+ var choice = TransportChooser.await(action, transport);
+ spec = specs.apply(choice.transport());
var response = requests.sendForStream(spec.request());
pending = response;
- body = Http.await(action, response, requests.stallTimeout(), spec.codes())
- .body();
+ try {
+ body = Http.await(action, response, requests.stallTimeout(), spec.codes())
+ .body();
+ } catch (RuntimeException ex) {
+ transports.forgetIfTheV2ApiIsMissing(choice, ex);
+ throw ex;
+ }
} catch (RuntimeException ex) {
throwIfClosed();
throw ex;
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java
index 665f68a..7dc0430 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java
@@ -26,6 +26,10 @@
// the request that asked fails, and the next request asks again. Requests
// that are made while the server is being asked wait for its answer rather
// than asking again, and fail along with it, each with its own action.
+//
+// A server that answers a request through the v2 API with 404 or 405 does not
+// offer it any longer, e.g. because it was rolled back to a version without
+// it, so the client forgets the answer, and the next request asks again.
final class TransportChooser {
private static final String PROBE_PATH = "/api/v2/system/ping";
@@ -34,8 +38,16 @@ final class TransportChooser {
private final V2Transport v2;
private final Object lock = new Object();
- private @Nullable Transport chosen;
- private @Nullable CompletableFuture probe;
+ private @Nullable Choice chosen;
+ private @Nullable CompletableFuture probe;
+ private long generation;
+
+ // A choice is the transport that a probe found out, along with the
+ // generation of that answer, which counts the answers that probes stored.
+ // The generation tells an answer apart from a later one that names the
+ // same transport, so that a request can tell which answer it was sent
+ // under.
+ record Choice(Transport transport, long generation) {}
TransportChooser(Requests requests, V1Transport v1, V2Transport v2) {
this.requests = requests;
@@ -43,10 +55,11 @@ final class TransportChooser {
this.v2 = v2;
}
- // Returns the transport, once the server has told which API it offers.
- // The future is the caller's own, so that canceling it ends only the wait
- // of the caller, not the probe, which other requests may wait for, too.
- CompletableFuture transport() {
+ // Returns the choice of the transport, once the server has told which API
+ // it offers. The future is the caller's own, so that canceling it ends
+ // only the wait of the caller, not the probe, which other requests may
+ // wait for, too.
+ CompletableFuture transport() {
synchronized (lock) {
var chosen = this.chosen;
if (chosen != null) {
@@ -65,14 +78,63 @@ CompletableFuture transport() {
@Nullable
Transport chosen() {
synchronized (lock) {
- return chosen;
+ var chosen = this.chosen;
+ return chosen != null ? chosen.transport() : null;
+ }
+ }
+
+ // Forgets the API once the server answered a request through the v2 API
+ // with 404 or 405, while the request fails as it does anyway. As for the
+ // probe, that holds whatever server sent the answer, so also for one that
+ // lacks the Server header: forgetting only makes the next request ask
+ // again, and the probe decides. A write fails that way with an
+ // OutcomeUnknownException, whose cause is the failure. Through the v1
+ // API, a 404 may be an error of its own, e.g. an event type that does not
+ // exist, and an error that the v2 API reports in its answer has the status
+ // code 200, so neither tells anything about the API.
+ //
+ // Only the answer that the request was sent under is forgotten, which
+ // its generation tells, so that a request answered late leaves alone the
+ // probe that another one started meanwhile, which other requests may wait
+ // for, and the answer that such a probe stored, even if it is the v2 API
+ // again.
+ void forgetIfTheV2ApiIsMissing(Choice choice, RuntimeException failure) {
+ var answer = failure instanceof OutcomeUnknownException ? failure.getCause() : failure;
+ if (choice.transport() != v2 || !isAnsweredWithoutTheV2Api(answer)) {
+ return;
}
+
+ synchronized (lock) {
+ var chosen = this.chosen;
+ if (chosen != null && chosen.generation() == choice.generation()) {
+ this.chosen = null;
+ }
+ }
+ }
+
+ // Tells whether a request failed because the server answered it with a
+ // status code that says that it does not offer the v2 API.
+ private static boolean isAnsweredWithoutTheV2Api(@Nullable Throwable answer) {
+ if (answer instanceof DbApiException exception) {
+ return isV2ApiMissing(exception.status());
+ }
+ if (answer instanceof InvalidServerHeaderException exception) {
+ return isV2ApiMissing(exception.status());
+ }
+
+ return false;
+ }
+
+ // A server answers the paths of the v2 API with 404 or 405 if it does not
+ // offer the v2 API, as the comment on the class tells.
+ private static boolean isV2ApiMissing(int status) {
+ return status == 404 || status == 405;
}
// Waits for the transport. A failed probe fails the request with an
// exception of its own, so that each request that waited for the probe
// names its own action.
- static Transport await(String action, CompletableFuture transport) {
+ static Choice await(String action, CompletableFuture transport) {
try {
return transport.get();
} catch (InterruptedException ex) {
@@ -88,7 +150,7 @@ static Transport await(String action, CompletableFuture transport) {
// The answer to the probe is small, so the stall timeout bounds the probe
// as a whole, which spares it a reader of its own.
- private CompletableFuture startProbe() {
+ private CompletableFuture startProbe() {
var response = requests.sendAsync(requests.get(PROBE_PATH), HttpResponse.BodyHandlers.ofString());
var stallTimeout = requests.stallTimeout();
@@ -98,7 +160,7 @@ private CompletableFuture startProbe() {
response.cancel(true);
});
- var result = new CompletableFuture();
+ var result = new CompletableFuture();
probe = result;
response.whenComplete((answer, failure) -> {
stallTimer.cancel(false);
@@ -108,12 +170,13 @@ private CompletableFuture startProbe() {
return result;
}
- // Keeps the transport that the probe found out, or forgets the probe if
- // it failed, so that the next request probes again. Either way, a probe
- // is running no longer. A probe starts only while no other is running, so
- // the probe that completes is the one that is kept.
+ // Keeps the transport that the probe found out, as an answer of a
+ // generation of its own, or forgets the probe if it failed, so that the
+ // next request probes again. Either way, a probe is running no longer. A
+ // probe starts only while no other is running, so the probe that
+ // completes is the one that is kept.
private void complete(
- CompletableFuture result,
+ CompletableFuture result,
@Nullable HttpResponse answer,
@Nullable Throwable failure,
boolean hasStalled) {
@@ -128,11 +191,14 @@ private void complete(
return;
}
+ Choice choice;
synchronized (lock) {
- chosen = transport;
+ generation++;
+ choice = new Choice(transport, generation);
+ chosen = choice;
probe = null;
}
- result.complete(transport);
+ result.complete(choice);
}
private Transport interpret(
@@ -145,11 +211,11 @@ private Transport interpret(
// proxy that passes on the v1 API only answers the probe itself. The
// requests to the v1 API check that the server is an EventSourcingDB.
var status = answer.statusCode();
- if (status == 404 || status == 405) {
+ if (isV2ApiMissing(status)) {
return v1;
}
if (!Http.isFromEventSourcingDb(answer)) {
- throw new ProbeFailure(action -> new InvalidServerHeaderException());
+ throw new ProbeFailure(action -> new InvalidServerHeaderException(status));
}
if (status != 200) {
var body = answer.body().getBytes(UTF_8);
diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java
index b133d67..12e1494 100644
--- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java
+++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java
@@ -8,6 +8,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.sun.net.httpserver.HttpExchange;
+import io.thenativeweb.eventsourcingdb.Operations.Operation;
import java.io.IOException;
import java.net.ConnectException;
import java.net.http.HttpTimeoutException;
@@ -21,15 +22,19 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
+import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import tools.jackson.core.JacksonException;
// The first request of a client asks the server whether it offers the v2 API,
// by pinging the v2 API. The client keeps a definitive answer, and asks again
-// after any other.
+// after any other, and once the server answers a request through the v2 API
+// with 404 or 405.
@Timeout(10)
class ProbeTest {
private static final Duration STALL_TIMEOUT = Duration.ofMillis(300);
@@ -40,7 +45,7 @@ class ProbeTest {
// A server that answers each probe with the next of the given answers,
// and every other request as EventSourcingDB 1.2.0 answers a ping or the
- // verification of an API token.
+ // verification of an API token, unless it is given an answer for them.
private static final class ProbedServer implements AutoCloseable {
private final FakeServer server;
private final AtomicInteger probeCount = new AtomicInteger();
@@ -51,22 +56,30 @@ interface Answer {
}
ProbedServer(Answer... answers) {
+ this(ProbedServer::answerAsV1, List.of(answers));
+ }
+
+ ProbedServer(Answer others, List answers) {
server = FakeServer.start((exchange, fakeServer) -> {
if (exchange.getRequestURI().getPath().equals(FakeServer.PROBE_PATH)) {
var index = probeCount.getAndIncrement();
- answers[Math.min(index, answers.length - 1)].answer(exchange, fakeServer);
+ answers.get(Math.min(index, answers.size() - 1)).answer(exchange, fakeServer);
return;
}
- FakeServer.respond(
- exchange,
- 200,
- exchange.getRequestURI().getPath().equals("/api/v1/verify-api-token")
- ? V1_API_TOKEN_VERIFIED
- : V1_PING_RECEIVED);
+ others.answer(exchange, fakeServer);
});
}
+ static void answerAsV1(HttpExchange exchange, FakeServer server) throws IOException {
+ FakeServer.respond(
+ exchange,
+ 200,
+ exchange.getRequestURI().getPath().equals("/api/v1/verify-api-token")
+ ? V1_API_TOKEN_VERIFIED
+ : V1_PING_RECEIVED);
+ }
+
Client client() {
return new Client(server.url(), "secret", new ClientOptions().withStallTimeout(STALL_TIMEOUT));
}
@@ -89,6 +102,15 @@ private static ProbedServer.Answer status(int status) {
return (exchange, server) -> FakeServer.respond(exchange, status, "status " + status);
}
+ // Answers with the given status as another server does, e.g. a proxy in
+ // front of the database, which sends a Server header of its own.
+ private static ProbedServer.Answer fromAnotherServer(int status) {
+ return (exchange, server) -> {
+ exchange.getResponseHeaders().set("Server", "nginx");
+ exchange.sendResponseHeaders(status, -1);
+ };
+ }
+
private static final ProbedServer.Answer V1 = status(404);
private static final ProbedServer.Answer V2 =
@@ -206,10 +228,7 @@ void failsTheRequestIfTheServerCanNotBeReached() {
@ParameterizedTest
@ValueSource(ints = {404, 405})
void usesTheV1ApiIfAnotherServerDoesNotKnowThePathOfThePing(int status) {
- try (var server = new ProbedServer((exchange, fakeServer) -> {
- exchange.getResponseHeaders().set("Server", "nginx");
- exchange.sendResponseHeaders(status, -1);
- })) {
+ try (var server = new ProbedServer(fromAnotherServer(status))) {
var client = server.client();
assertDoesNotThrow(client::ping);
@@ -238,12 +257,7 @@ void usesTheV1ApiIfAnotherServerAnswersThePingWithAPage(int status) {
@ParameterizedTest
@ValueSource(ints = {200, 500})
void failsTheRequestIfAnotherServerAnswersTheProbeOtherwise(int status) {
- try (var server = new ProbedServer(
- (exchange, fakeServer) -> {
- exchange.getResponseHeaders().set("Server", "nginx");
- exchange.sendResponseHeaders(status, -1);
- },
- V1)) {
+ try (var server = new ProbedServer(fromAnotherServer(status), V1)) {
var client = server.client();
assertThrows(InvalidServerHeaderException.class, client::ping);
@@ -299,6 +313,19 @@ private static void awaitWaiting(List threads) {
}
}
+ // Runs the request in a thread of its own, which puts what the request
+ // threw into the outcomes under the given key, or nothing if it succeeded.
+ private static Thread start(K key, Runnable request, Map> outcomes) {
+ return Thread.ofPlatform().start(() -> {
+ try {
+ request.run();
+ outcomes.put(key, Optional.empty());
+ } catch (RuntimeException ex) {
+ outcomes.put(key, Optional.of(ex));
+ }
+ });
+ }
+
// Runs each of the requests in a thread of its own, releases the probe
// once all of them wait for it, and returns what each of them threw, or
// null for those that succeeded.
@@ -309,16 +336,7 @@ private static Map> runWhileTheProbeWaits(
var threads = new ArrayList();
for (var index = 0; index < requests.size(); index++) {
- var request = requests.get(index);
- var requestIndex = index;
- threads.add(Thread.ofPlatform().start(() -> {
- try {
- request.run();
- outcomes.put(requestIndex, Optional.empty());
- } catch (RuntimeException ex) {
- outcomes.put(requestIndex, Optional.of(ex));
- }
- }));
+ threads.add(start(index, requests.get(index), outcomes));
}
awaitWaiting(threads);
@@ -439,4 +457,251 @@ void aCloseEndsAStreamThatWaitsForTheProbe() throws InterruptedException {
closing.join();
}
}
+
+ // Answers every path of the v2 API with the given answer, and the v1 API
+ // as EventSourcingDB 1.2.0 does.
+ private static ProbedServer.Answer withoutTheV2Api(ProbedServer.Answer v2Answer) {
+ return (exchange, server) -> {
+ if (exchange.getRequestURI().getPath().startsWith("/api/v2/")) {
+ v2Answer.answer(exchange, server);
+ return;
+ }
+
+ ProbedServer.answerAsV1(exchange, server);
+ };
+ }
+
+ // A server that was rolled back to a version without the v2 API answers
+ // every path of the v2 API with the given status, as EventSourcingDB
+ // 1.2.0 does, and the v1 API as it did before.
+ private static ProbedServer.Answer rolledBack(int status) {
+ return withoutTheV2Api(status(status));
+ }
+
+ // The ping of the v2 API goes to the path of the probe, which the server
+ // answers as a probe, so only the other requests are made here.
+ static Stream everyAuthenticatedRequestWith404Or405() {
+ return Stream.of(404, 405)
+ .flatMap(status ->
+ Operations.authenticated().stream().map(operation -> Arguments.of(status, operation)));
+ }
+
+ // A server that offered the v2 API when the client asked, and was rolled
+ // back to a version without it afterwards, answers the next request
+ // through the v2 API with 404 or 405. That request fails as any other
+ // that the server refuses, a write with an unknown outcome, and the next
+ // request asks again which API the server offers.
+ @ParameterizedTest
+ @MethodSource("everyAuthenticatedRequestWith404Or405")
+ void failsTheRequestAndAsksAgainIfTheServerNoLongerOffersTheV2Api(int status, Operation operation) {
+ try (var server = new ProbedServer(rolledBack(status), List.of(V2, V1))) {
+ var client = server.client();
+
+ var exception = Operations.assertFailsAfterSending(DbApiException.class, operation, client);
+ assertDoesNotThrow(client::verifyApiToken);
+
+ assertEquals(status, exception.status());
+ assertEquals(2, server.probeCount());
+ assertEquals("/api/v1/verify-api-token", server.paths().getLast());
+ }
+ }
+
+ // A proxy in front of the database that passes on the v1 API only, e.g.
+ // one set up again along with a rollback, answers the paths of the v2 API
+ // itself, without the Server header of EventSourcingDB. The request fails
+ // for the missing header as before, a write with an unknown outcome, and
+ // the next request asks again, which the proxy answers itself, too.
+ @ParameterizedTest
+ @MethodSource("everyAuthenticatedRequestWith404Or405")
+ void failsTheRequestAndAsksAgainIfAProxyNoLongerPassesOnTheV2Api(int status, Operation operation) {
+ try (var server =
+ new ProbedServer(withoutTheV2Api(fromAnotherServer(status)), List.of(V2, fromAnotherServer(status)))) {
+ var client = server.client();
+
+ Operations.assertFailsAfterSending(InvalidServerHeaderException.class, operation, client);
+ assertDoesNotThrow(client::verifyApiToken);
+
+ assertEquals(2, server.probeCount());
+ assertEquals("/api/v1/verify-api-token", server.paths().getLast());
+ }
+ }
+
+ // An error that the v2 API reports in its answer has the status code 200,
+ // and tells nothing about the API, so the client keeps the v2 API.
+ @Test
+ void keepsTheV2ApiIfTheServerReportsAnErrorInItsAnswer() {
+ var error = ApiVersion.V2.item(
+ "error",
+ "{\"code\":\"" + ErrorCode.EVENT_TYPE_UNKNOWN + "\",\"description\":\"event type is unknown\"}");
+
+ try (var server =
+ new ProbedServer((exchange, fakeServer) -> FakeServer.respond(exchange, 200, error), List.of(V2, V1))) {
+ var client = server.client();
+
+ var exception = assertThrows(DbApiException.class, () -> client.readEventType("io.x.y"));
+ assertThrows(DbApiException.class, () -> client.readEventType("io.x.y"));
+
+ assertEquals(200, exception.status());
+ assertEquals(
+ List.of(FakeServer.PROBE_PATH, "/api/v2/data/read-event-type", "/api/v2/data/read-event-type"),
+ server.paths());
+ }
+ }
+
+ // Through the v1 API, a 404 may be an error of its own, e.g. an event type
+ // that does not exist, so the client keeps the v1 API.
+ @Test
+ void keepsTheV1ApiIfTheServerAnswersARequestWith404() {
+ try (var server = new ProbedServer(
+ (exchange, fakeServer) -> FakeServer.respond(exchange, 404, "event type 'io.x.y' not found"),
+ List.of(V1, V2))) {
+ var client = server.client();
+
+ var exception = assertThrows(DbApiException.class, () -> client.readEventType("io.x.y"));
+ assertThrows(DbApiException.class, () -> client.readEventType("io.x.y"));
+
+ assertEquals(404, exception.status());
+ assertEquals(
+ List.of(FakeServer.PROBE_PATH, "/api/v1/read-event-type", "/api/v1/read-event-type"),
+ server.paths());
+ }
+ }
+
+ // A gate holds an answer of the server until the test opens it, and tells
+ // the test once an answer waits at it.
+ private record Gate(CountDownLatch reached, CountDownLatch opened) {
+ Gate() {
+ this(new CountDownLatch(1), new CountDownLatch(1));
+ }
+
+ void pass() throws InterruptedException {
+ reached.countDown();
+ opened.await();
+ }
+
+ void awaitReached() throws InterruptedException {
+ reached.await();
+ }
+
+ void open() {
+ opened.countDown();
+ }
+ }
+
+ // Requests through the v2 API that were sent before the server was rolled
+ // back may be answered with 404 only after another one made the client ask
+ // again, while the new probe runs, or once it has its answer. Neither
+ // disturbs the probe: the requests that wait for it, and those that start
+ // to wait afterwards, share it, and its answer is kept.
+ @Test
+ void aLate404LeavesTheNewProbeAndItsAnswerAlone() throws InterruptedException {
+ var duringProbe = new Gate();
+ var afterProbe = new Gate();
+ var newProbe = new Gate();
+ var heldRequests = List.of(duringProbe, afterProbe);
+ var v2RequestCount = new AtomicInteger();
+
+ ProbedServer.Answer others = (exchange, fakeServer) -> {
+ if (exchange.getRequestURI().getPath().startsWith("/api/v2/")) {
+ var index = v2RequestCount.getAndIncrement();
+ if (index < heldRequests.size()) {
+ heldRequests.get(index).pass();
+ }
+ }
+
+ rolledBack(404).answer(exchange, fakeServer);
+ };
+ ProbedServer.Answer heldProbe = (exchange, fakeServer) -> {
+ newProbe.pass();
+ V1.answer(exchange, fakeServer);
+ };
+
+ try (var server = new ProbedServer(others, List.of(V2, heldProbe, V1))) {
+ // The client's own stall timeout leaves the gates all the time
+ // they need.
+ var client = new Client(server.server.url(), "secret");
+ var outcomes = new ConcurrentHashMap>();
+
+ var lateDuringProbe = start("late during probe", client::verifyApiToken, outcomes);
+ duringProbe.awaitReached();
+ var lateAfterProbe = start("late after probe", client::verifyApiToken, outcomes);
+ afterProbe.awaitReached();
+
+ assertThrows(DbApiException.class, client::verifyApiToken);
+ assertNull(client.chosenTransport());
+
+ var waiting = start("waiting", client::verifyApiToken, outcomes);
+ newProbe.awaitReached();
+ duringProbe.open();
+ lateDuringProbe.join();
+
+ var joining = start("joining", client::verifyApiToken, outcomes);
+ while (joining.isAlive() && joining.getState() != Thread.State.WAITING) {
+ Thread.onSpinWait();
+ }
+ newProbe.open();
+ waiting.join();
+ joining.join();
+
+ afterProbe.open();
+ lateAfterProbe.join();
+ assertDoesNotThrow(client::verifyApiToken);
+
+ for (var late : List.of("late during probe", "late after probe")) {
+ var exception = assertInstanceOf(
+ DbApiException.class, outcomes.get(late).orElseThrow());
+ assertEquals(404, exception.status());
+ }
+ assertEquals(Optional.empty(), outcomes.get("waiting"));
+ assertEquals(Optional.empty(), outcomes.get("joining"));
+ assertEquals(2, server.probeCount());
+ assertEquals("/api/v1/verify-api-token", server.paths().getLast());
+ }
+ }
+
+ // A request through the v2 API that was sent before the server was rolled
+ // back may be answered with 404 only once a new probe found out the v2 API
+ // again, e.g. because the server was rolled forward meanwhile. That 404
+ // belongs to the answer that the request was sent under, so the answer of
+ // the new probe is kept.
+ @Test
+ void aLate404LeavesTheV2ApiThatANewProbeFoundOutAlone() throws InterruptedException {
+ var late = new Gate();
+ var v2RequestCount = new AtomicInteger();
+ var tokenVerified = ApiVersion.V2.item("event", "{\"type\":\"io.eventsourcingdb.api.token-verified\"}");
+
+ ProbedServer.Answer others = (exchange, fakeServer) -> {
+ var index = v2RequestCount.getAndIncrement();
+ if (index == 0) {
+ late.pass();
+ }
+ if (index < 2) {
+ status(404).answer(exchange, fakeServer);
+ return;
+ }
+
+ FakeServer.respond(exchange, 200, tokenVerified);
+ };
+
+ try (var server = new ProbedServer(others, List.of(V2))) {
+ // The client's own stall timeout leaves the gate all the time it
+ // needs.
+ var client = new Client(server.server.url(), "secret");
+ var outcomes = new ConcurrentHashMap>();
+
+ var lateRequest = start("late", client::verifyApiToken, outcomes);
+ late.awaitReached();
+ assertThrows(DbApiException.class, client::verifyApiToken);
+ assertDoesNotThrow(client::verifyApiToken);
+
+ late.open();
+ lateRequest.join();
+ assertDoesNotThrow(client::verifyApiToken);
+
+ var exception =
+ assertInstanceOf(DbApiException.class, outcomes.get("late").orElseThrow());
+ assertEquals(404, exception.status());
+ assertEquals(2, server.probeCount());
+ }
+ }
}