Skip to content

Commit 978fc6f

Browse files
gkatz2claude
andcommitted
Add AuthRetrying state for transient OAuth errors
When an OAuth refresh fails as a transient error for longer than the in-loop short retry (~1-2 minutes at defaults), the monitor used to mark the workload unauthenticated and exit. The workload stayed dead even after the underlying condition cleared on its own (e.g. a brief VPN disconnect routing requests through a different network path). Now the monitor keeps retrying on a configurable longer cadence (default 10 minutes) until either success or a configurable ceiling (default 24 hours), at which point the workload is finally marked unauthenticated. Fixes #5349 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Greg Katz <gkatz@indeed.com>
1 parent 83e9eae commit 978fc6f

20 files changed

Lines changed: 1342 additions & 62 deletions

cmd/thv/app/common.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,14 @@ func completeLogsArgs(cmd *cobra.Command, args []string, _ string) ([]string, co
157157
}
158158

159159
// workloadStatusIndicator returns the status string with a visual indicator prepended
160-
// for statuses that warrant user attention (unauthenticated, policy_stopped).
160+
// for statuses that warrant user attention (unauthenticated, auth_retrying, policy_stopped).
161161
// All other statuses are returned as plain strings.
162162
func workloadStatusIndicator(status runtime.WorkloadStatus) string {
163163
switch status {
164164
case runtime.WorkloadStatusUnauthenticated:
165165
return "⚠️ " + string(status)
166+
case runtime.WorkloadStatusAuthRetrying:
167+
return "🔄 " + string(status)
166168
case runtime.WorkloadStatusPolicyStopped:
167169
return "🚫 " + string(status)
168170
case runtime.WorkloadStatusRunning, runtime.WorkloadStatusStopped, runtime.WorkloadStatusError,

cmd/thv/app/common_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
package app
55

66
import (
7+
"strings"
78
"testing"
89

910
"github.com/spf13/cobra"
11+
12+
"github.com/stacklok/toolhive/pkg/container/runtime"
1013
)
1114

1215
func TestAddFormatFlag(t *testing.T) {
@@ -266,3 +269,38 @@ func TestIsOIDCEnabled(t *testing.T) {
266269
})
267270
}
268271
}
272+
273+
func TestWorkloadStatusIndicator(t *testing.T) {
274+
t.Parallel()
275+
tests := []struct {
276+
name string
277+
status runtime.WorkloadStatus
278+
wantHas string // substring that must appear
279+
wantExact string // if non-empty, must match exactly
280+
}{
281+
{"unauthenticated has ⚠️ prefix", runtime.WorkloadStatusUnauthenticated, "⚠️", ""},
282+
{"auth_retrying has 🔄 prefix", runtime.WorkloadStatusAuthRetrying, "🔄", ""},
283+
{"policy_stopped has 🚫 prefix", runtime.WorkloadStatusPolicyStopped, "🚫", ""},
284+
{"running passes through plain", runtime.WorkloadStatusRunning, "", "running"},
285+
{"stopped passes through plain", runtime.WorkloadStatusStopped, "", "stopped"},
286+
{"unhealthy passes through plain", runtime.WorkloadStatusUnhealthy, "", "unhealthy"},
287+
}
288+
for _, tc := range tests {
289+
t.Run(tc.name, func(t *testing.T) {
290+
t.Parallel()
291+
got := workloadStatusIndicator(tc.status)
292+
if tc.wantExact != "" && got != tc.wantExact {
293+
t.Errorf("workloadStatusIndicator(%q) = %q, want exact %q",
294+
tc.status, got, tc.wantExact)
295+
}
296+
if tc.wantHas != "" && !strings.Contains(got, tc.wantHas) {
297+
t.Errorf("workloadStatusIndicator(%q) = %q, want substring %q",
298+
tc.status, got, tc.wantHas)
299+
}
300+
if !strings.Contains(got, string(tc.status)) {
301+
t.Errorf("workloadStatusIndicator(%q) = %q, must include status name",
302+
tc.status, got)
303+
}
304+
})
305+
}
306+
}

cmd/thv/app/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ func printTextOutput(workloadList []core.Workload, upgrades map[string]*upgrade.
248248

249249
// Print workload information
250250
for _, c := range workloadList {
251-
// Highlight unauthenticated and policy-stopped workloads with indicators
251+
// Highlight unauthenticated, auth-retrying, and policy-stopped workloads with indicators
252252
status := workloadStatusIndicator(c.Status)
253253

254254
// Print workload information

cmd/thv/app/ui/styles.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ var (
7171
pillUnauthed = lipgloss.NewStyle().
7272
Background(bgWarning).Foreground(ColorYellow).
7373
Padding(0, 1).Render("⚠ unauthed")
74+
pillAuthRetrying = lipgloss.NewStyle().
75+
Background(bgWarning).Foreground(ColorYellow).
76+
Padding(0, 1).Render("🔄 retrying")
7477

7578
keyStyle = lipgloss.NewStyle().Foreground(ColorDim2)
7679
portStyle = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true)
@@ -97,6 +100,8 @@ func RenderStatusDot(status rt.WorkloadStatus) string {
97100
return dotWarning
98101
case rt.WorkloadStatusUnauthenticated:
99102
return dotWarning
103+
case rt.WorkloadStatusAuthRetrying:
104+
return dotWarning
100105
case rt.WorkloadStatusRemoving:
101106
return dotWarning
102107
case rt.WorkloadStatusPolicyStopped:
@@ -128,6 +133,8 @@ func RenderStatusPill(status rt.WorkloadStatus) string {
128133
return pillUnknown
129134
case rt.WorkloadStatusUnauthenticated:
130135
return pillUnauthed
136+
case rt.WorkloadStatusAuthRetrying:
137+
return pillAuthRetrying
131138
case rt.WorkloadStatusPolicyStopped:
132139
return pillStopped
133140
default:

docs/arch/02-core-concepts.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ A **workload** is the fundamental deployment unit in ToolHive. It represents eve
3838
- `error` - Workload encountered an error
3939
- `unhealthy` - Workload is running but unhealthy
4040
- `unauthenticated` - Remote workload cannot authenticate (expired tokens)
41+
- `auth_retrying` - Remote workload's token refresh is failing transiently; monitor is still retrying until success (→ `running`) or the configured ceiling (→ `unauthenticated`)
4142
- `unknown` - Workload status cannot be determined
4243
- `policy_stopped` - Workload was stopped by policy enforcement
4344

docs/arch/08-workloads-lifecycle.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ stateDiagram-v2
2222
Running --> Stopping: Stop
2323
Running --> Unhealthy: Health Failed
2424
Running --> Unauthenticated: Auth Failed
25+
Running --> AuthRetrying: Transient Token Refresh Failures
2526
Running --> Stopped: Container Exit
2627
2728
Stopping --> Stopped: Success
@@ -31,16 +32,27 @@ stateDiagram-v2
3132
Unauthenticated --> Starting: Re-authenticate
3233
Unauthenticated --> Removing: Delete
3334
35+
AuthRetrying --> Running: Refresh Succeeds
36+
AuthRetrying --> Unauthenticated: Ceiling Exceeded or Permanent Error
37+
3438
Removing --> [*]: Success
3539
Error --> Starting: Restart
3640
Error --> Removing: Delete
3741
```
3842

3943
**States**: `pkg/container/runtime/types.go`
4044
- `starting`, `running`, `stopping`, `stopped`
41-
- `removing`, `error`, `unhealthy`, `unauthenticated`
45+
- `removing`, `error`, `unhealthy`, `unauthenticated`, `auth_retrying`
4246
- `policy_stopped`, `unknown`
4347

48+
The `auth_retrying` cadence and ceiling can be tuned via environment
49+
variables on the proxy process:
50+
51+
- `TOOLHIVE_TOKEN_AUTH_RETRYING_TICK_INTERVAL` (default `10m`): cadence
52+
between background refresh attempts during the AuthRetrying window.
53+
- `TOOLHIVE_TOKEN_AUTH_RETRYING_MAX_ELAPSED` (default `24h`): ceiling
54+
before the workload is finally marked `unauthenticated`.
55+
4456
## Core Operations
4557

4658
### Deploy

docs/server/docs.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/api/v1/workload_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type workloadListResponse struct {
3333
type workloadStatusResponse struct {
3434
// Current status of the workload
3535
//nolint:lll // enums tag needed for swagger generation with --parseDependencyLevel
36-
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,policy_stopped"`
36+
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,auth_retrying,policy_stopped"`
3737
}
3838

3939
// updateRequest represents the request to update an existing workload

0 commit comments

Comments
 (0)