Skip to content

Commit fda9a56

Browse files
JAORMXclaude
andauthored
Add RetryDelay to ErrorHandling in WorkflowStep CRD (#2776)
The VirtualMCPServer CRD's ErrorHandling struct was missing the RetryDelay field that is supported by the implementation. This adds the field to allow users to configure the delay between retry attempts for workflow steps. Changes: - Add RetryDelay field to ErrorHandling struct with duration pattern validation - Update converter to parse and pass through the retry delay - Add comprehensive tests for error handling conversion Fixes #2773 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent 3b3ec11 commit fda9a56

File tree

8 files changed

+248
-3
lines changed

8 files changed

+248
-3
lines changed

cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,12 @@ type ErrorHandling struct {
275275
// Only used when Action is "retry"
276276
// +optional
277277
MaxRetries int `json:"maxRetries,omitempty"`
278+
279+
// RetryDelay is the delay between retry attempts
280+
// Only used when Action is "retry"
281+
// +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ms|s|m))+$`
282+
// +optional
283+
RetryDelay string `json:"retryDelay,omitempty"`
278284
}
279285

280286
// TokenCacheConfig configures token caching behavior

cmd/thv-operator/pkg/vmcpconfig/converter.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,16 @@ func (*Converter) convertCompositeTools(
304304

305305
// Convert error handling
306306
if crdStep.OnError != nil {
307-
step.OnError = &vmcpconfig.StepErrorHandling{
307+
stepError := &vmcpconfig.StepErrorHandling{
308308
Action: crdStep.OnError.Action,
309309
RetryCount: crdStep.OnError.MaxRetries,
310310
}
311+
if crdStep.OnError.RetryDelay != "" {
312+
if duration, err := time.ParseDuration(crdStep.OnError.RetryDelay); err == nil {
313+
stepError.RetryDelay = vmcpconfig.Duration(duration)
314+
}
315+
}
316+
step.OnError = stepError
311317
}
312318

313319
tool.Steps = append(tool.Steps, step)

cmd/thv-operator/pkg/vmcpconfig/converter_test.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
// Package vmcpconfig provides conversion logic from VirtualMCPServer CRD to vmcp Config
12
package vmcpconfig
23

34
import (
45
"context"
56
"testing"
7+
"time"
68

79
"github.com/go-logr/logr"
810
"github.com/stretchr/testify/assert"
@@ -12,6 +14,7 @@ import (
1214
"sigs.k8s.io/controller-runtime/pkg/log"
1315

1416
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
17+
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
1518
)
1619

1720
func TestConvertCompositeTools_Parameters(t *testing.T) {
@@ -236,3 +239,220 @@ func TestConvertCompositeTools_Timeout(t *testing.T) {
236239
})
237240
}
238241
}
242+
243+
func TestConverter_ConvertCompositeTools_ErrorHandling(t *testing.T) {
244+
t.Parallel()
245+
246+
tests := []struct {
247+
name string
248+
errorHandling *mcpv1alpha1.ErrorHandling
249+
expectedAction string
250+
expectedRetry int
251+
expectedDelay vmcpconfig.Duration
252+
}{
253+
{
254+
name: "with retry delay",
255+
errorHandling: &mcpv1alpha1.ErrorHandling{
256+
Action: "retry",
257+
MaxRetries: 3,
258+
RetryDelay: "5s",
259+
},
260+
expectedAction: "retry",
261+
expectedRetry: 3,
262+
expectedDelay: vmcpconfig.Duration(5 * time.Second),
263+
},
264+
{
265+
name: "with millisecond retry delay",
266+
errorHandling: &mcpv1alpha1.ErrorHandling{
267+
Action: "retry",
268+
MaxRetries: 5,
269+
RetryDelay: "500ms",
270+
},
271+
expectedAction: "retry",
272+
expectedRetry: 5,
273+
expectedDelay: vmcpconfig.Duration(500 * time.Millisecond),
274+
},
275+
{
276+
name: "with minute retry delay",
277+
errorHandling: &mcpv1alpha1.ErrorHandling{
278+
Action: "retry",
279+
MaxRetries: 2,
280+
RetryDelay: "1m",
281+
},
282+
expectedAction: "retry",
283+
expectedRetry: 2,
284+
expectedDelay: vmcpconfig.Duration(1 * time.Minute),
285+
},
286+
{
287+
name: "without retry delay",
288+
errorHandling: &mcpv1alpha1.ErrorHandling{
289+
Action: "retry",
290+
MaxRetries: 3,
291+
},
292+
expectedAction: "retry",
293+
expectedRetry: 3,
294+
expectedDelay: vmcpconfig.Duration(0),
295+
},
296+
{
297+
name: "abort action",
298+
errorHandling: &mcpv1alpha1.ErrorHandling{
299+
Action: "abort",
300+
},
301+
expectedAction: "abort",
302+
expectedRetry: 0,
303+
expectedDelay: vmcpconfig.Duration(0),
304+
},
305+
{
306+
name: "continue action",
307+
errorHandling: &mcpv1alpha1.ErrorHandling{
308+
Action: "continue",
309+
},
310+
expectedAction: "continue",
311+
expectedRetry: 0,
312+
expectedDelay: vmcpconfig.Duration(0),
313+
},
314+
{
315+
name: "invalid retry delay format is ignored",
316+
errorHandling: &mcpv1alpha1.ErrorHandling{
317+
Action: "retry",
318+
MaxRetries: 3,
319+
RetryDelay: "invalid",
320+
},
321+
expectedAction: "retry",
322+
expectedRetry: 3,
323+
expectedDelay: vmcpconfig.Duration(0),
324+
},
325+
}
326+
327+
for _, tt := range tests {
328+
t.Run(tt.name, func(t *testing.T) {
329+
t.Parallel()
330+
331+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
332+
ObjectMeta: metav1.ObjectMeta{
333+
Name: "test-vmcp",
334+
Namespace: "default",
335+
},
336+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
337+
GroupRef: mcpv1alpha1.GroupRef{Name: "test-group"},
338+
CompositeTools: []mcpv1alpha1.CompositeToolSpec{
339+
{
340+
Name: "test-tool",
341+
Description: "A test composite tool",
342+
Steps: []mcpv1alpha1.WorkflowStep{
343+
{
344+
ID: "step1",
345+
Type: "tool",
346+
Tool: "backend/some-tool",
347+
OnError: tt.errorHandling,
348+
},
349+
},
350+
},
351+
},
352+
},
353+
}
354+
355+
converter := NewConverter()
356+
ctx := log.IntoContext(context.Background(), logr.Discard())
357+
config, err := converter.Convert(ctx, vmcpServer)
358+
359+
require.NoError(t, err)
360+
require.NotNil(t, config)
361+
require.Len(t, config.CompositeTools, 1)
362+
require.Len(t, config.CompositeTools[0].Steps, 1)
363+
364+
step := config.CompositeTools[0].Steps[0]
365+
if tt.errorHandling != nil {
366+
require.NotNil(t, step.OnError)
367+
assert.Equal(t, tt.expectedAction, step.OnError.Action)
368+
assert.Equal(t, tt.expectedRetry, step.OnError.RetryCount)
369+
assert.Equal(t, tt.expectedDelay, step.OnError.RetryDelay)
370+
} else {
371+
assert.Nil(t, step.OnError)
372+
}
373+
})
374+
}
375+
}
376+
377+
func TestConverter_ConvertCompositeTools_NoErrorHandling(t *testing.T) {
378+
t.Parallel()
379+
380+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
381+
ObjectMeta: metav1.ObjectMeta{
382+
Name: "test-vmcp",
383+
Namespace: "default",
384+
},
385+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
386+
GroupRef: mcpv1alpha1.GroupRef{Name: "test-group"},
387+
CompositeTools: []mcpv1alpha1.CompositeToolSpec{
388+
{
389+
Name: "test-tool",
390+
Description: "A test composite tool",
391+
Steps: []mcpv1alpha1.WorkflowStep{
392+
{
393+
ID: "step1",
394+
Type: "tool",
395+
Tool: "backend/some-tool",
396+
// No OnError specified
397+
},
398+
},
399+
},
400+
},
401+
},
402+
}
403+
404+
converter := NewConverter()
405+
ctx := log.IntoContext(context.Background(), logr.Discard())
406+
config, err := converter.Convert(ctx, vmcpServer)
407+
408+
require.NoError(t, err)
409+
require.NotNil(t, config)
410+
require.Len(t, config.CompositeTools, 1)
411+
require.Len(t, config.CompositeTools[0].Steps, 1)
412+
413+
step := config.CompositeTools[0].Steps[0]
414+
assert.Nil(t, step.OnError)
415+
}
416+
417+
func TestConverter_ConvertCompositeTools_StepTimeout(t *testing.T) {
418+
t.Parallel()
419+
420+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
421+
ObjectMeta: metav1.ObjectMeta{
422+
Name: "test-vmcp",
423+
Namespace: "default",
424+
},
425+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
426+
GroupRef: mcpv1alpha1.GroupRef{Name: "test-group"},
427+
CompositeTools: []mcpv1alpha1.CompositeToolSpec{
428+
{
429+
Name: "test-tool",
430+
Description: "A test composite tool",
431+
Timeout: "30s",
432+
Steps: []mcpv1alpha1.WorkflowStep{
433+
{
434+
ID: "step1",
435+
Type: "tool",
436+
Tool: "backend/some-tool",
437+
Timeout: "10s",
438+
},
439+
},
440+
},
441+
},
442+
},
443+
}
444+
445+
converter := NewConverter()
446+
ctx := log.IntoContext(context.Background(), logr.Discard())
447+
config, err := converter.Convert(ctx, vmcpServer)
448+
449+
require.NoError(t, err)
450+
require.NotNil(t, config)
451+
require.Len(t, config.CompositeTools, 1)
452+
453+
tool := config.CompositeTools[0]
454+
assert.Equal(t, vmcpconfig.Duration(30*time.Second), tool.Timeout)
455+
456+
require.Len(t, tool.Steps, 1)
457+
assert.Equal(t, vmcpconfig.Duration(10*time.Second), tool.Steps[0].Timeout)
458+
}

deploy/charts/operator-crds/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: toolhive-operator-crds
33
description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
44
type: application
5-
version: 0.0.67
5+
version: 0.0.68
66
appVersion: "0.0.1"

deploy/charts/operator-crds/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ToolHive Operator CRDs Helm Chart
22

3-
![Version: 0.0.67](https://img.shields.io/badge/Version-0.0.67-informational?style=flat-square)
3+
![Version: 0.0.68](https://img.shields.io/badge/Version-0.0.68-informational?style=flat-square)
44
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
55

66
A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.

deploy/charts/operator-crds/crds/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ spec:
155155
MaxRetries is the maximum number of retries
156156
Only used when Action is "retry"
157157
type: integer
158+
retryDelay:
159+
description: |-
160+
RetryDelay is the delay between retry attempts
161+
Only used when Action is "retry"
162+
pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$
163+
type: string
158164
type: object
159165
schema:
160166
description: Schema defines the expected response schema for

deploy/charts/operator-crds/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ spec:
247247
MaxRetries is the maximum number of retries
248248
Only used when Action is "retry"
249249
type: integer
250+
retryDelay:
251+
description: |-
252+
RetryDelay is the delay between retry attempts
253+
Only used when Action is "retry"
254+
pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$
255+
type: string
250256
type: object
251257
schema:
252258
description: Schema defines the expected response schema

docs/operator/crd-api.md

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

0 commit comments

Comments
 (0)