Maas client tests and retry cases improvement - #173
Conversation
alsergs
left a comment
There was a problem hiding this comment.
Review: PR #173 — maas client tests and retry cases improvement
Reviewed at 895b45687, after the rebase onto main and the move to Failsafe.
Verdict
The retry policy and the watch-loop rework fix real failures that show up during a rolling node replacement, and the
backoff has a regression test that fails against main. Replacing the hand-rolled retry loop with Failsafe removed a
class of arithmetic bugs and most of the bookkeeping. Three items should be settled before merge: a retried DELETE
that reports a completed delete as a failure, a stray interrupt that kills the watch thread for good, and a narrowed
catch in Env that lets a config-provider failure escape.
Scope note. The ticket asks for coverage of leader failover and switchover in the maas client libs. This PR covers the
control plane, meaning the REST channel between the client and maas-agent. The data plane, where a broker leader change
actually lands, is untouched: rabbit-blue-green opens a channel per binding through ChannelSupplier with no retry,
and kafka-blue-green-consumer has no failover test. Worth stating in the PR body so the ticket does not look closed.
Fixed issues
-
Hot spin on a failed watch poll.
watchTenantCreateTopicscaught the exception, logged it and immediately
looped. With maas-agent unreachable the connection is refused in microseconds, so the loop ran thousands of times
per second, each iteration writing an ERROR with a stack trace.awaitWatchBackoffnow applies a linear backoff
capped at 30s, reset on success. Waiting onwatchLockrather thanThread.sleepalso letsclose()cut the
backoff short instead of waiting out the full pause. -
NullPointerExceptionindeleteTopic. The old code null-checkedrespin theif, then dereferenced it on
the next line. A 200 with an empty body reaches that path, becausesendAndReceivefilters an empty body to
Optional.empty(). Now handled atKafkaMaaSClientImpl.java:116. -
Missed signal in the watch park path. The old
wait()was not guarded by a condition. Between theisEmpty()
check that ended the poll loop and thewait()there was aclosedcheck and a logging call. AwatchTopicCreate
landing in that window sent itsnotify()before anyone waited, and the watch thread parked forever holding a
non-empty listener map, so the callback never fired. The guarded wait at:223closes it. -
Lock held on the
Threadobject.synchronized (watchThread.get())shares a monitor withThread.join(), which
close()calls. A singlenotify()could wake the joiner instead of the watch loop. Now a privatewatchLock, and
notifyAll()rather thannotify(). -
Watch window longer than the read timeout. The window was a fixed 60s against a 30s default read timeout, so
every quiet long poll died locally and counted as a failure.watchTimeout()now derives the window from
maas.http.timeout, giving 25s by default. -
No retry on transient failover responses.
HttpExecutionretried only onIOException. It now also retries
5xx, 429, a read-only 405 from maas-service, and refreshes once on 401, under a FailsafeRetryPolicybounded by
maas.http.retry.max-total-duration-mswith a 60s default. Per-attempt bounding usesCall.timeout(), so no client
is rebuilt per attempt. -
watchTopicCreateafterclose(). It used to register a callback that could never fire. It now throws
IllegalStateException.
Test coverage
Covered, and the tests fail against main:
- Issue 1:
KafkaMaaSClientWatchBackoffTest.failingWatchPollIsBackedOffInsteadOfHotLoopingstands up an HTTP stub that
answers every poll with 500, timestamps the arrivals, and asserts the first pause exceeds 500ms and the second
exceeds the first. Good test. - Issue 5:
watchWindowStaysBelowTheReadTimeout, with the gap noted below. - Issue 6:
HttpExecutionFailoverTest, now parameterized, covering the retryable statuses, the permanent ones, the 401
cap,noRetry(), a zero budget, a hanging attempt, interrupt handling and cause chaining.RabbitFailoverTestadds
the same shape end to end throughgetOrCreateVirtualHost. KafkaMaaSClientCloseTest.closeStopsWatchThreadis a good addition: it asserts the named thread is no longer alive
afterclose(), which pins the shutdown path that issues 3 and 4 sit on.
Not covered, and worth adding:
- Issue 2 needs one line. No test sends a 200 with an empty body, so the fixed
NullPointerExceptionhas no
regression test.testTopicDeleteSuccessuses{"deletedSuccessfully": [], "failedToDelete": []}, which is a
different path. Add a.respond(response())case. - Issue 7 has no test. One
assertThrows(IllegalStateException.class, …)afterclose()covers it. - Issues 3 and 4 have no test. Nothing exercises a notify arriving between the
isEmpty()check and thewait(),
or anotify()waking ajoin().closeStopsWatchThreadandtestWatchTopicCreatewalk the park and wake path,
but both pass onmaintoo, so neither would have caught either bug. Both races are hard to drive deterministically
without a hook at the park point, which is an argument for changing the mechanism rather than for writing the test.
See the first suggestion. - The boundary in issue 5 is not covered.
watchWindowStaysBelowTheReadTimeoutchecks
{2, 5, 6, 10, 30, 60, 120}, but the invariant breaks atmaas.http.timeout=1, where the window comes out at 1s and
equals the read timeout, and for sub-second timeouts, whereDuration.getSeconds()truncates to 0. Add 1 to the set
and fix the clamp.
Issues to fix before merge
- A retried
DELETEreports a successful delete as a failure.deleteTopicis not idempotent, and retrying on
5xx makes it at-least-once. If maas-service deletes the topic and then the response is lost, the second attempt
returns empty lists anddeleteTopicreturnsfalse, telling the caller the topic still exists.getOrCreateTopic
withOnTopicExists.FAILhas the same shape.noRetry()is currently used only by the watch poll; both of these
need it, or explicit duplicate handling. - A stray interrupt kills the watch thread permanently. When the thread is interrupted while
closedis false,
pollWhileThereIsSomethingToWatchreturns false and the thread exits.watchThreadis aLazythat stays
initialized, so the nextwatchTopicCreatepasses theclosedguard, registers a callback, gets the dead thread
back, and notifies a monitor nobody waits on. Nothing restarts the thread and nothing logs an error. Envno longer catches a config-provider failure. The probe atEnv.java:236narrowed fromThrowableto
ClassNotFoundException | NoClassDefFoundError. If MicroProfile Config is present but its provider fails during
class initialization, a duplicate or broken SmallRyeConfigSourcebeing the common case, the resulting
ExceptionInInitializerErroris anErrorthat is neither of those. It now escapesstringProperty, soapiUrl(),
httpTimeout()andhttpRetryMaxTotalDuration()all throw, where the client previously fell back to system
properties and environment variables. CatchingLinkageErroras well restores the old behavior.
Suggestions
Replace the monitor with a Semaphore. The guarded wait is correct, but its correctness rests on an invariant that
a later edit can break silently: move the isEmpty() check out of the loop, or notify before mutating the map, and the
missed signal returns with no test failure. A Semaphore remembers a permit released before anyone waits, so the race
cannot exist:
private final Semaphore watchSignal = new Semaphore(0);
// park:
watchSignal.drainPermits();
watchSignal.acquire();
// wake, from watchTopicCreate and close():
watchSignal.release();This also removes the third coordination mechanism from the thread. Right now closed, the watchLock monitor and
Thread.interrupt() all steer the same thread, and the interrupt being load-bearing in shutdown is exactly why a stray
interrupt is indistinguishable from a close. A timed acquire covers the backoff wait as well, so both uses of
watchLock go away together.
Reconsider the 401 retry, and move it to an OkHttp Authenticator. Capping at one is the right call, and the
reasoning in the comment holds: nothing in this stack can invalidate a token, so attempt N+1 sends whatever the
supplier returns, and a second retry is provably useless. The cap itself matters more than the retry, because 401 is in
isRetryableStatus and without the cap a bad credential would spin for the whole 60s budget on every call. Three
things are worth changing around it.
First, the retry buys less than it looks like. M2MInterceptor calls the token supplier on every request, immediately
before chain.proceed, so the gap between minting the header and the server validating it is one network round trip.
The only case the retry covers is a token crossing its expiry boundary inside that gap, where the supplier's own
expiry check fires on the next call. Real, but narrow.
Second, the retry costs more than one request. M2MInterceptor already handles 401 by re-sending with the Keycloak
token, and alterRequest(…, useFallbackUrl=true) rebases the URL, so the fallback goes to a different host and port.
UrlCache.store runs only when the fallback succeeds, so a URL that keeps failing is never cached and every attempt
repeats Kubernetes token, 401, Keycloak token, 401. One 401 retry then puts four requests on the wire rather than two.
The tests do not show this because they run with KUBERNETES_M2M_ENABLED unset.
Third, the Failsafe policy applies the same backoff to every retryable failure, 401 included. Auth has no
rate-limiting semantics here: either the supplier can mint a fresh token now or it cannot. The delay adds a second of
latency to the common auth-failure path for nothing.
An OkHttp Authenticator addresses all three. It runs inside the call, below the interceptor, so it retries the auth
without re-running the fallback and rebase; it is handed the failed response and must build a new request, which makes
the refresh explicit rather than incidental; and it caps follow-ups itself. Worth checking with the security library
owners whether refresh belongs there rather than here, given that M2MInterceptor already owns 401 and maas-client
does not own the token supplier.
While in this code, the give-up message does not say that credentials were the problem. A dedicated message for the
exhausted-auth case saves a debugging session.
Move authAttempts into the execution. HttpExecution is a single-use, thread-confined builder, and every call
site consumes it in one chained expression, so the mutable fields cause no problem today. authAttempts is the
awkward one: it is an instance field reset at the top of sendAndReceive() and then incremented from inside the
Failsafe handleIf predicate, which means per-call retry state now lives outside the retry policy that owns
everything else. Holding it in a small per-call object captured by the policy lambda keeps the reset from being
load-bearing.
Worth noting that expectedCodes, errorHandler, retryEnabled and req are mutated after construction with no
synchronization, and final freezes the reference rather than the contents. Building an HttpExecution on one thread
and running it on another would have no happens-before edge. Nothing does that, and the class does not need to support
it, but the builder chain looks like something a caller might reasonably cache in a field. A sentence on the class
saying it is single-use and confined to one thread would settle it.
Tighten the read-only 405 match. Every maas-service error envelope carries MAAS-0600, so the decision rests on a
substring search for read-only anywhere in the body. A permanent 405 whose message mentions a read-only field gets
retried for the full budget. Match the reason field, or the Postgres code 25006.
Consider an upper clamp on the retry budget. parseRetryDurationMillis rejects a negative value but not an absurd
one, so maas.http.retry.max-total-duration-ms=9223372036854775807 reaches Failsafe as a Duration of roughly 292
million years. The nanosecond overflow that this used to cause in the hand-rolled deadline is gone, and I did not
verify how Failsafe handles a Duration that large, so this is cheap insurance rather than a known defect.
Relax one timing assertion. testMaxTotalDuration_AbortsBeforeAttemptsExhausted allows 800ms for a 200ms budget
while running real requests against MockServer. The margin is 600ms across several attempts, backoff delays and JVM
scheduling; one GC pause makes it fail. Asserting the exception rather than the clock removes the flake.
Interceptor, optional. Moving the Failsafe policy into an OkHttp Interceptor would cover calls that bypass
HttpExecution, such as the websocket client from HttpClient.getClient(), and would turn noRetry() into a
Request.tag() rather than mutable state. Lower value than it was before Call.timeout() replaced the per-attempt
client rebuild. Fine to leave for later.
Minor
- The CHANGELOG and README use
behaviour; the rest of the maas-client docs use American spelling. - The PR body is empty. Given that the change spans a new dependency, a rewritten retry path and a reworked watch
thread, a short Why, What and How to verify would help the reviewer. - Adding Failsafe is a new runtime dependency for every consumer of maas-client. Worth one line in the CHANGELOG, since
downstream services with dependency convergence rules will see it.
Not fixed after review, with reasons
|
|
Reverted: the Semaphore. Rolled back to Introduced: a busy-wait for the whole pause. Offered: nothing. The missed-signal race does not exist in the monitor version — |
alsergs
left a comment
There was a problem hiding this comment.
Re-reviewed at 4774f001, against my previous review at 895b45687.
Addressed
Envcatch narrowing. Fixed, and better than what I suggested: catchesThrowable(not just
addingLinkageError), with a comment explaining why anErrorhas to be caught here.- Retried
DELETE.deleteTopicnow calls.noRetry(), with a CHANGELOG entry explaining why it
is deliberately not retried. - Missing empty-body delete test. Added —
testTopicDeleteEmptyBodycovers theNullPointerException
fix directly. keySet()handed to Jackson without the map's lock. Fixed:watchedClassifiers()now copies the
key set undersynchronized (topicCreateListeners)before it is serialized.- Flaky timing assertion in
testMaxTotalDuration_AbortsBeforeAttemptsExhausted. Fixed as suggested:
theelapsedMs < 800clock check is replaced withVerificationTimes.atMost(10)on the attempt count. - 401 retry. Removed entirely rather than moved to an
Authenticator. The new comment matches what I
found tracingM2MInterceptor: it already makes its own 401 round trip, and nothing in this stack can
force a token refresh, so the retry only ever covered a narrow expiry-boundary window. Dropping it is a
reasonable, more conservative resolution than theAuthenticatormove I suggested — no objection to it. - Failsafe as a new dependency. Now called out in the CHANGELOG, including that it pulls no
transitives but will still show up under dependency convergence rules. watchBackoffMillisis now extracted and unit-tested on its own, andEnv's retry-duration parsing
(zero, negative, unparseable, whitespace) has direct test coverage.
Still open
- A stray interrupt kills the watch thread permanently. Not fixed. The log line improved — it is now
log.error, names the affected classifiers, and tells the operator to recreate the client — but the
thread still exits with nothing that restarts it and no state that later calls can detect. Better
diagnostics, same underlying bug:watchThreadstaysinitialized, so a laterwatchTopicCreate
silently registers a callback that will never fire. getOrCreateTopicwithOnTopicExists.FAILis still retried. Same shape as thedeleteTopicissue
that was fixed: a lost response after a completed create makes the retry hit a topic that already
exists and fail permanently.deleteTopicgot.noRetry(); this call site did not.- The watch-window boundary is still untested.
watchWindowStaysBelowTheReadTimeoutchecks
{2, 5, 6, 10, 30, 60, 120}. The invariant it exists to protect breaks atmaas.http.timeout=1, where
the window equals the read timeout, and for sub-second timeouts, whereDuration.getSeconds()
truncates to 0. Adding1to the data set would catch it. - No test for
watchTopicCreatethrowing afterclose(). OneassertThrows(IllegalStateException.class, …)
would cover it.
Net
Six of eight points from the previous review landed cleanly, one (401) was resolved differently but
soundly. The two remaining items — the permanently dead watch thread after a stray interrupt, and the
retried getOrCreateTopic — are worth closing before merge; the two test gaps are cheap follow-ups.
|
| TopicDeleteResponse resp = httpClient.request(apiProvider.getKafkaTopicUrl(null)) | ||
| .delete(new TopicDeleteRequest(classifier)) | ||
| .expect(HTTP_OK) | ||
| .noRetry() |
There was a problem hiding this comment.
Why does deleteTopicTemplate still have retries? Shouldn't we keep them consistent?



MaaS client: retry behaviour for a storage failover
A database leader switchover arrives at the client as
405, not as5xx, so the usual"retry 5xx, fail fast on 4xx" rule does not survive it. This adds the retry policy that does,
and fixes the watch loop that spun instead of backing off.
Scope. This covers the control plane — the REST channel between the client and maas-agent.
The data plane is untouched:
rabbit-blue-greenopens a channel per binding with no retry, andkafka-blue-green-consumerhas no failover test. The ticket is not fully closed by this PR.What changed
Retries now key on the response, not only on
IOException. Retryable:5xx,429, and405when the body carries a maas-service error naming a read-only database. A plain405froman ingress or a removed route stays permanent and fails fast.
401 is not retried. The token source refreshes on its own polling interval, so a retry inside
the backoff re-sends the same token, and
M2MInterceptorhas already made its own 401 round tripby the time the response reaches us.
One knob bounds the whole call:
maas.http.retry.max-total-duration-ms, 60s by default.Attempt count and backoff growth are derived from it, and each attempt is capped by what is left
via
Call.timeout(), so the worst case a caller sees is that duration rather than the durationplus one
maas.http.timeout. Zero disables retries. Backoff is exponential with jitter, delegatedto Failsafe.
deleteTopicis deliberately not retried. It is not idempotent: a lost response after acompleted delete would make the next attempt return empty lists, and the method would report the
topic as still present.
The watch long poll is excluded from the policy through
HttpExecution.noRetry(): it owns itsown loop, which got a capped backoff instead of retrying with no delay. Its window is derived from
maas.http.timeout(25s with the defaults) instead of a fixed 60s that outlived the read timeout,so every quiet poll used to die locally instead of returning an empty
200. The window nowtravels in milliseconds, so it stays below the read timeout for small timeouts too.
Watch thread lifecycle. Parking moved from the thread object to a private monitor —
join()in
close()waited on the same monitor and could consume the notification meant for the loop. Thewait is guarded against spurious wakeups, the backoff waits on that monitor so
close()cuts itshort, and
watchTopicCreateafterclose()throws instead of registering a callback that cannever fire.
Failures throw
MaaSHttpExceptioninstead of a bareRuntimeException. It extendsMaaSException, so existingcatchblocks keep working — note the widening: they now also catchtransport failures.
Two pre-existing NPE/
NoSuchElementExceptionpaths fixed along the way: an empty body indeleteTopicand insearch.New dependency
dev.failsafe:failsafe3.3.2, compile scope, no transitive dependencies. It replaces ahand-written loop with backoff, jitter, attempt counting and deadline handling. Services with
dependency convergence rules will see it appear.
How to verify
The backoff and watch-window tests fail against
main.Tests
HttpExecutionFailoverTesttestFailover_RetryableResponseSucceedsOnRetrytestFailover_PermanentResponseNotRetriedtestMaxTotalDuration_BoundsAHangingAttempttestNoRetry_SendsExactlyOneAttemptnoRetry()sends one requesttestZeroTotalDuration_SendsOneAttemptAndDoesNotRetrytestInterrupt_RestoresFlagAndAbortsRetryLooptestTotalDurationExceeded_CarriesTheLastFailureAsCausetestMaxTotalDuration_AbortsBeforeAttemptsExhaustedRabbitFailoverTesttestFailover_RetryableResponseSucceedsOnRetrytestFailover_400NotRetriedKafkaMaaSClientWatchBackoffTestwatchWindowStaysBelowTheReadTimeoutmaas.http.timeout, down to a one-second timeoutbackoffGrowsWithConsecutiveFailuresfailingWatchPollIsBackedOffInsteadOfHotLoopingElsewhere
EnvTestcovers the new property: default, explicit value, zero, and the fallback for anon-numeric, empty or negative one.
KafkaMaaSClientImplTestadds the empty-body delete that usedto throw
NullPointerException.