Skip to content

Commit a4c6176

Browse files
pditommasoclaude
andauthored
Quieten expected Platform-connectivity errors in the token watcher (#1085)
* Quieten expected Platform-connectivity errors in the token watcher When the workflow-status lookup fails because the run's Platform endpoint is briefly unreachable (e.g. a paired self-hosted instance whose pairing WebSocket times out), the watcher caught it in the generic catch-all and logged a full ERROR stack trace on every retry. For chronically-unreachable paired endpoints this floods the logs and inflates error-rate signals, even though the behavior is correct (the token is not renewed and lapses fail-closed, then the entry stops on the next 'gone' check). Distinguish these transient connectivity failures (TimeoutException / HttpResponseException in the cause chain) from genuinely unexpected errors: log the former as a concise one-line warning, keep the ERROR+stack trace for the latter. Retry and the wave.tokens.refresh{result=error} counter are unchanged for both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bound cause-chain walk to guard against cyclic references Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Replace empty for-loop with while in rootCauseMessage Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Simplify cause-chain walk per review feedback Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1bfc55c commit a4c6176

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

src/main/groovy/io/seqera/wave/service/request/ContainerRequestServiceImpl.groovy

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ package io.seqera.wave.service.request
2020

2121
import java.time.Duration
2222
import java.time.Instant
23+
import java.util.concurrent.TimeoutException
2324

2425
import groovy.transform.CompileStatic
2526
import groovy.util.logging.Slf4j
2627
import io.micrometer.core.instrument.MeterRegistry
2728
import io.micronaut.core.annotation.Nullable
2829
import io.micronaut.scheduling.TaskScheduler
2930
import io.seqera.wave.configuration.ContainerRequestConfig
31+
import io.seqera.wave.exception.HttpResponseException
3032
import io.seqera.wave.service.persistence.PersistenceService
3133
import io.seqera.wave.service.persistence.WaveContainerRecord
3234
import io.seqera.wave.service.request.ContainerRequestRange.Entry
@@ -132,6 +134,33 @@ class ContainerRequestServiceImpl implements ContainerRequestService {
132134
meterRegistry?.counter('wave.tokens.refresh', 'result', result)?.increment()
133135
}
134136

137+
private static final int MAX_CAUSE_DEPTH = 50
138+
139+
/**
140+
* Whether the given error is an expected Platform connectivity failure (a timeout or an HTTP
141+
* error reaching Tower) as opposed to an unexpected bug. Such failures are transient and retried,
142+
* so they should not be logged at ERROR with a full stack trace. The cause chain is walked up to
143+
* {@link #MAX_CAUSE_DEPTH} to guard against cyclic cause references.
144+
*/
145+
protected static boolean isConnectivityError(Throwable t) {
146+
Throwable e = t
147+
for( int i = 0; e != null && i < MAX_CAUSE_DEPTH; e = e.cause, i++ ) {
148+
if( e instanceof TimeoutException || e instanceof HttpResponseException )
149+
return true
150+
}
151+
return false
152+
}
153+
154+
protected static String rootCauseMessage(Throwable t) {
155+
if( t == null )
156+
return null
157+
Throwable e = t
158+
int i = 0
159+
while( e.cause != null && e.cause != e && i++ < MAX_CAUSE_DEPTH )
160+
e = e.cause
161+
return e.message ?: e.class.simpleName
162+
}
163+
135164
protected void scheduleRefresh(Entry entry) {
136165
// Re-check this request one refreshInterval from now. refreshInterval is deliberately shorter
137166
// than accessTtl so several checks fit inside a token's lifetime — a transient Tower failure
@@ -173,11 +202,20 @@ class ContainerRequestServiceImpl implements ContainerRequestService {
173202
Thread.currentThread().interrupt()
174203
}
175204
catch (Throwable t) {
176-
log.error("Unexpected error in container request watcher while processing key: $it", t)
177205
meterRefresh('error')
178206
// The entry was already popped by getEntriesUntil; re-add it so a transient failure
179207
// (e.g. a Tower blip) does not silently drop the request and let its token lapse.
180208
scheduleRefresh(it)
209+
if( isConnectivityError(t) ) {
210+
// Expected when the workflow's Platform endpoint is briefly unreachable — e.g. a
211+
// paired self-hosted instance whose pairing WebSocket times out. The token is not
212+
// renewed, so it lapses fail-closed; log a concise warning rather than a full
213+
// stack trace to avoid flooding the logs while the entry is retried.
214+
log.warn "Container request watcher could not confirm workflow status - request=${it.requestId}; workflow=${it.workflowId}; cause=${rootCauseMessage(t)}; will retry"
215+
}
216+
else {
217+
log.error("Unexpected error in container request watcher while processing key: $it", t)
218+
}
181219
}
182220
}
183221
}

src/test/groovy/io/seqera/wave/service/request/ContainerRequestServiceImplTest.groovy

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ import spock.lang.Specification
2222

2323
import java.time.Duration
2424
import java.time.Instant
25+
import java.util.concurrent.ExecutionException
26+
import java.util.concurrent.TimeoutException
2527

2628
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
2729
import io.micronaut.test.extensions.spock.annotation.MicronautTest
2830
import io.seqera.wave.configuration.ContainerRequestConfig
31+
import io.seqera.wave.exception.HttpResponseException
2932
import io.seqera.wave.service.persistence.PersistenceService
3033
import io.seqera.wave.service.persistence.WaveContainerRecord
3134
import io.seqera.wave.tower.PlatformId
@@ -253,4 +256,23 @@ class ContainerRequestServiceImplTest extends Specification {
253256
0 * range.add(_, _)
254257
}
255258
259+
def 'isConnectivityError should detect Tower connectivity failures in the cause chain'() {
260+
expect:
261+
ContainerRequestServiceImpl.isConnectivityError(ERR) == EXPECTED
262+
where:
263+
ERR | EXPECTED
264+
new TimeoutException('nope') | true
265+
new HttpResponseException(408, 'Timeout') | true
266+
new RuntimeException('wrap', new TimeoutException('nope')) | true
267+
new ExecutionException(new HttpResponseException(408, 'Timeout')) | true
268+
new RuntimeException('boom') | false
269+
new NullPointerException() | false
270+
}
271+
272+
def 'rootCauseMessage should return the deepest cause message'() {
273+
expect:
274+
ContainerRequestServiceImpl.rootCauseMessage(new RuntimeException('outer', new TimeoutException('inner'))) == 'inner'
275+
ContainerRequestServiceImpl.rootCauseMessage(new RuntimeException('solo')) == 'solo'
276+
}
277+
256278
}

0 commit comments

Comments
 (0)