Skip to content

Commit 1b7fecd

Browse files
glasstigerclaude
andcommitted
Fail fast on QWP token-provider failures
Address three review follow-ups in the OIDC device-flow client. QwpQueryClient resolved the bearer token inside the per-endpoint upgrade, so a token-provider failure (not signed in, a failed silent refresh, a rejected token) was caught as a per-endpoint transport error and reported as "all QWP endpoints unreachable", and the provider was queried once per endpoint. Resolve the header once before the endpoint walk in connect() and reconnectViaTracker() and thread it through connectToEndpoint/runUpgradeWithTimeout, so a provider failure - which is cluster-wide - propagates directly with the provider's own message and the provider is queried once per connect/reconnect. Strengthen testThrowingProviderFailsConnect to assert the provider's own exception surfaces, not a wrapped "unreachable" error. Validate Builder.httpTimeoutMillis: a non-positive value gave an already-expired read deadline and an unbounded recv(int), so reject it like Sender.Builder already does. Add a test. Drop the always-true scopeEncoded null check in tryRefresh: build() defaults scope to DEFAULT_SCOPE, so append it unconditionally like runDeviceFlow(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 272d704 commit 1b7fecd

4 files changed

Lines changed: 52 additions & 20 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,9 +1294,7 @@ private boolean tryRefresh() {
12941294
formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED);
12951295
appendParam(formSink, "refresh_token", refreshToken);
12961296
appendEncodedParam(formSink, "client_id", clientIdEncoded);
1297-
if (scopeEncoded != null) {
1298-
appendEncodedParam(formSink, "scope", scopeEncoded);
1299-
}
1297+
appendEncodedParam(formSink, "scope", scopeEncoded);
13001298
if (audienceEncoded != null) {
13011299
appendEncodedParam(formSink, "audience", audienceEncoded);
13021300
}
@@ -1421,6 +1419,9 @@ public Builder groupsInToken(boolean groupsInToken) {
14211419
}
14221420

14231421
public Builder httpTimeoutMillis(int httpTimeoutMillis) {
1422+
if (httpTimeoutMillis <= 0) {
1423+
throw new OidcAuthException("httpTimeoutMillis must be positive");
1424+
}
14241425
this.httpTimeoutMillis = httpTimeoutMillis;
14251426
return this;
14261427
}

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,11 @@ public void close() {
684684
* observed so callers can distinguish "no primary available" from "all
685685
* endpoints unreachable" (the latter surfaces as a plain
686686
* {@link HttpClientException}).
687+
* <p>
688+
* A configured token provider is queried once here, before the walk. A
689+
* provider failure (not signed in, a failed silent refresh, a rejected
690+
* token) is cluster-wide, so it fails fast with the provider's own error
691+
* rather than being retried across endpoints as a transport failure.
687692
*/
688693
public synchronized void connect() {
689694
if (closedFlag.get()) {
@@ -702,14 +707,20 @@ public synchronized void connect() {
702707
QwpServerInfo lastObservedMismatch = null;
703708
QwpIngressRoleRejectedException lastUpgradeRoleReject = null;
704709
Throwable lastTransportError = null;
710+
// Resolve the bearer credential once, before the endpoint walk: a token is cluster-wide, so a
711+
// token-provider failure (not signed in, a failed silent refresh, a rejected token) is not a
712+
// per-endpoint transport fault. Resolving here lets it propagate as the provider's own error
713+
// instead of being folded into "all endpoints unreachable", and avoids re-querying the provider
714+
// once per endpoint.
715+
String authHeader = resolveAuthorizationHeader();
705716
while (true) {
706717
int i = hostTracker.pickNext();
707718
if (i < 0) {
708719
break;
709720
}
710721
Endpoint ep = endpoints.get(i);
711722
try {
712-
connectToEndpoint(ep);
723+
connectToEndpoint(ep, authHeader);
713724
} catch (QwpAuthFailedException ae) {
714725
cleanupFailedConnect();
715726
throw ae;
@@ -1401,7 +1412,7 @@ private void cleanupFailedConnect() {
14011412
currentEndpointIndex = -1;
14021413
}
14031414

1404-
private void connectToEndpoint(Endpoint ep) {
1415+
private void connectToEndpoint(Endpoint ep, String authHeader) {
14051416
if (tlsEnabled) {
14061417
webSocketClient = WebSocketClientFactory.newTlsInstance(
14071418
new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode));
@@ -1412,7 +1423,7 @@ private void connectToEndpoint(Endpoint ep) {
14121423
webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId());
14131424
webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader());
14141425
webSocketClient.setQwpMaxBatchRows(maxBatchRows);
1415-
runUpgradeWithTimeout(ep);
1426+
runUpgradeWithTimeout(ep, authHeader);
14161427
negotiatedQwpVersion = webSocketClient.getServerQwpVersion();
14171428
negotiatedZstdLevel = webSocketClient.getServerNegotiatedZstdLevel();
14181429

@@ -1730,6 +1741,10 @@ private void reconnectViaTracker() {
17301741
QwpServerInfo lastMismatch = null;
17311742
Throwable lastError = null;
17321743
boolean retriedAfterReset = false;
1744+
// Resolve the bearer credential once per reconnect, before the endpoint walk, for the same
1745+
// reason as connect(): a provider failure is cluster-wide, so surface it directly rather than
1746+
// as a per-endpoint transport error retried across every host.
1747+
String authHeader = resolveAuthorizationHeader();
17331748
while (true) {
17341749
int i = hostTracker.pickNext();
17351750
if (i < 0) {
@@ -1742,7 +1757,7 @@ private void reconnectViaTracker() {
17421757
}
17431758
Endpoint ep = endpoints.get(i);
17441759
try {
1745-
connectToEndpoint(ep);
1760+
connectToEndpoint(ep, authHeader);
17461761
} catch (QwpAuthFailedException ae) {
17471762
cleanupFailedConnect();
17481763
throw ae;
@@ -1788,12 +1803,11 @@ private void reconnectViaTracker() {
17881803
}
17891804

17901805
private String resolveAuthorizationHeader() {
1791-
// With a token provider, re-query it at each upgrade so a reconnect
1792-
// presents a freshly refreshed token; validateToken rejects a
1793-
// null/empty/blank return, or one carrying a control or non-ASCII
1794-
// character, before it reaches the "Bearer " header. A provider that
1795-
// throws (a failed silent refresh) propagates and fails this connection
1796-
// attempt, matching the QWP ingress sender.
1806+
// With a token provider, query it once per connect()/reconnect (the caller resolves before the
1807+
// endpoint walk) so a reconnect presents a freshly refreshed token; validateToken rejects a
1808+
// null/empty/blank return, or one carrying a control or non-ASCII character, before it reaches
1809+
// the "Bearer " header. A provider that throws (a failed silent refresh, or not signed in yet)
1810+
// propagates out of connect()/reconnect with its own message, matching the QWP ingress sender.
17971811
if (tokenProvider != null) {
17981812
CharSequence token = tokenProvider.getToken();
17991813
HttpTokenProvider.validateToken(token);
@@ -1802,9 +1816,8 @@ private String resolveAuthorizationHeader() {
18021816
return authorizationHeader;
18031817
}
18041818

1805-
private void runUpgradeWithTimeout(Endpoint ep) {
1819+
private void runUpgradeWithTimeout(Endpoint ep, String authHeader) {
18061820
int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE);
1807-
String authHeader = resolveAuthorizationHeader();
18081821
try {
18091822
webSocketClient.connect(ep.host, ep.port);
18101823
webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader);

core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,20 @@ public void testBuilderRejectsMissingRequiredOptions() {
266266
}
267267
}
268268

269+
@Test(timeout = 30_000)
270+
public void testBuilderRejectsNonPositiveHttpTimeout() {
271+
// every other timing input is clamped; a non-positive HTTP timeout yields an already-expired read
272+
// deadline and an unbounded recv(int), so the setter rejects it (matching Sender.Builder)
273+
for (int bad : new int[]{0, -1}) {
274+
try {
275+
OidcDeviceAuth.builder().httpTimeoutMillis(bad);
276+
Assert.fail("expected httpTimeoutMillis(" + bad + ") to be rejected");
277+
} catch (OidcAuthException e) {
278+
Assert.assertTrue(e.getMessage(), e.getMessage().contains("httpTimeoutMillis"));
279+
}
280+
}
281+
}
282+
269283
@Test(timeout = 30_000)
270284
public void testBuilderRejectsSplitOriginEndpoints() {
271285
// the token and device authorization endpoints are on different origins; RFC 8628 co-locates them

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientTokenProviderTest.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,9 @@ public void testProviderSynthesizesBearerHeader() {
128128

129129
@Test(timeout = 15_000)
130130
public void testProviderTokenSentOnRealUpgrade() throws Exception {
131-
// drive the REAL connect path (runUpgradeWithTimeout -> resolveAuthorizationHeader), not the test
132-
// hook: the upgrade request must carry the freshly pulled "Bearer <token>". The mock answers 404
133-
// (not auth-failed, not terminal) so connect() fails fast after the header was already sent.
131+
// drive the REAL connect path (connect() -> resolveAuthorizationHeader -> runUpgradeWithTimeout),
132+
// not the test hook: the upgrade request must carry the freshly pulled "Bearer <token>". The mock
133+
// answers 404 (not auth-failed, not terminal) so connect() fails fast after the header was sent.
134134
List<String> authHeaders = Collections.synchronizedList(new ArrayList<>());
135135
ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
136136
int port = listener.getLocalPort();
@@ -213,8 +213,8 @@ public void testSettingBearerTokenThenProviderConflicts() {
213213
@Test(timeout = 10_000)
214214
public void testThrowingProviderFailsConnect() throws Exception {
215215
// a provider that throws must fail the connection attempt on the REAL connect path:
216-
// resolveAuthorizationHeader runs inside runUpgradeWithTimeout, before the socket connect, so the
217-
// throw aborts the upgrade; connect() exhausts the single endpoint and surfaces the provider failure
216+
// resolveAuthorizationHeader runs once before the endpoint walk, so the throw propagates straight
217+
// out of connect() as the provider's own error (not wrapped as "all endpoints unreachable")
218218
try (
219219
ServerSocket listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
220220
QwpQueryClient client = QwpQueryClient.fromConfig(
@@ -227,7 +227,11 @@ public void testThrowingProviderFailsConnect() throws Exception {
227227
client.connect();
228228
Assert.fail("a throwing provider must fail the connection attempt");
229229
} catch (RuntimeException expected) {
230+
// the provider's own exception propagates directly (the header is resolved before the
231+
// endpoint walk), not wrapped as a transport "all endpoints unreachable" error
232+
Assert.assertTrue(expected.getClass().getName(), expected instanceof LineSenderException);
230233
Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("provider down"));
234+
Assert.assertFalse(expected.getMessage(), expected.getMessage().contains("unreachable"));
231235
}
232236
}
233237
}

0 commit comments

Comments
 (0)