Skip to content

Commit b5ee1b7

Browse files
authored
Extension framework - propagate error suggestions from event handlers and custom service targets (#7791)
1 parent d4bc97b commit b5ee1b7

18 files changed

Lines changed: 950 additions & 56 deletions

cli/azd/cmd/middleware/error.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
2525
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
2626
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
27+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
2728
"github.com/azure/azure-dev/cli/azd/pkg/config"
2829
"github.com/azure/azure-dev/cli/azd/pkg/environment"
2930
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
@@ -175,6 +176,12 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action
175176
return actionResult, err
176177
}
177178

179+
// Skip the YAML pipeline for typed extension errors so host-side rules don't
180+
// override the extension's structured classification or user guidance.
181+
if azdext.IsStructuredError(err) {
182+
return actionResult, err
183+
}
184+
178185
// Try to match error against known patterns and wrap with suggestion
179186
if suggestion := e.errorPipeline.Process(ctx, err); suggestion != nil {
180187
return actionResult, suggestion

cli/azd/cmd/middleware/error_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
1919
"github.com/azure/azure-dev/cli/azd/pkg/auth"
2020
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
21+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
2122
"github.com/azure/azure-dev/cli/azd/pkg/config"
2223
"github.com/azure/azure-dev/cli/azd/pkg/environment"
2324
"github.com/azure/azure-dev/cli/azd/pkg/errorhandler"
@@ -231,6 +232,108 @@ func Test_ErrorMiddleware_PatternMatchingSuggestion(t *testing.T) {
231232
require.NotEmpty(t, suggestionErr.Links, "Expected reference links")
232233
}
233234

235+
// Test_ErrorMiddleware_ExtensionErrorWithSuggestion_BypassesPipeline verifies that
236+
// when an extension-supplied error (LocalError or ServiceError) already carries a
237+
// Suggestion, the YAML error-suggestion pipeline is short-circuited so it doesn't
238+
// override the extension's specific guidance with a generic one. Regression test
239+
// for https://github.com/Azure/azure-dev/issues/7706.
240+
func Test_ErrorMiddleware_ExtensionErrorWithSuggestion_BypassesPipeline(t *testing.T) {
241+
t.Parallel()
242+
243+
mockContext := mocks.NewMockContext(context.Background())
244+
cfg := config.NewEmptyConfig()
245+
featureManager := alpha.NewFeaturesManagerWithConfig(cfg)
246+
global := &internal.GlobalCommandOptions{
247+
NoPrompt: true, // skip agentic handling
248+
}
249+
userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager)
250+
errorPipeline := errorhandler.NewErrorHandlerPipeline(nil)
251+
middleware := NewErrorMiddleware(
252+
&Options{Name: "test"},
253+
mockContext.Console,
254+
nil,
255+
global,
256+
featureManager,
257+
userConfigManager,
258+
errorPipeline,
259+
)
260+
261+
// "QuotaExceeded" matches a known pattern in error_suggestions.yaml that would
262+
// otherwise wrap into an *ErrorWithSuggestion (proven by
263+
// Test_ErrorMiddleware_PatternMatchingSuggestion). The middleware must skip
264+
// that wrapping when the extension already supplied a suggestion of its own.
265+
extErr := &azdext.LocalError{
266+
Message: "Deployment failed: QuotaExceeded for resource",
267+
Code: "quota_exceeded",
268+
Category: azdext.LocalErrorCategoryUser,
269+
Suggestion: "Extension-provided guidance: request a quota increase via the portal.",
270+
}
271+
nextFn := func(ctx context.Context) (*actions.ActionResult, error) {
272+
return nil, extErr
273+
}
274+
275+
result, err := middleware.Run(*mockContext.Context, nextFn)
276+
277+
require.Error(t, err)
278+
require.Nil(t, result)
279+
280+
// The original LocalError must be returned unchanged — not wrapped in
281+
// *ErrorWithSuggestion by the YAML pipeline.
282+
var wrapped *internal.ErrorWithSuggestion
283+
require.False(
284+
t,
285+
errors.As(err, &wrapped),
286+
"YAML pipeline should not wrap an extension error that already has a Suggestion",
287+
)
288+
returned, ok := errors.AsType[*azdext.LocalError](err)
289+
require.True(t, ok, "expected the original *LocalError to be returned")
290+
require.Same(t, extErr, returned)
291+
require.Equal(t, extErr.Suggestion, azdext.ErrorSuggestion(err))
292+
}
293+
294+
func Test_ErrorMiddleware_StructuredExtensionErrorWithoutSuggestion_BypassesPipeline(t *testing.T) {
295+
t.Parallel()
296+
297+
mockContext := mocks.NewMockContext(context.Background())
298+
cfg := config.NewEmptyConfig()
299+
featureManager := alpha.NewFeaturesManagerWithConfig(cfg)
300+
global := &internal.GlobalCommandOptions{
301+
NoPrompt: true,
302+
}
303+
userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager)
304+
errorPipeline := errorhandler.NewErrorHandlerPipeline(nil)
305+
middleware := NewErrorMiddleware(
306+
&Options{Name: "test"},
307+
mockContext.Console,
308+
nil,
309+
global,
310+
featureManager,
311+
userConfigManager,
312+
errorPipeline,
313+
)
314+
315+
extErr := &azdext.LocalError{
316+
Message: "Deployment failed: QuotaExceeded for resource",
317+
Code: "quota_exceeded",
318+
Category: azdext.LocalErrorCategoryUser,
319+
}
320+
nextFn := func(ctx context.Context) (*actions.ActionResult, error) {
321+
return nil, extErr
322+
}
323+
324+
result, err := middleware.Run(*mockContext.Context, nextFn)
325+
326+
require.Error(t, err)
327+
require.Nil(t, result)
328+
329+
var wrapped *internal.ErrorWithSuggestion
330+
require.False(t, errors.As(err, &wrapped))
331+
returned, ok := errors.AsType[*azdext.LocalError](err)
332+
require.True(t, ok)
333+
require.Same(t, extErr, returned)
334+
require.Empty(t, azdext.ErrorSuggestion(err))
335+
}
336+
234337
func Test_ErrorMiddleware_NoPatternMatch(t *testing.T) {
235338
t.Parallel()
236339
mockContext := mocks.NewMockContext(context.Background())

cli/azd/cmd/middleware/middleware_coverage2_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,34 @@ func TestUxMiddleware_Run_AzdextServiceErrorWithSuggestion(t *testing.T) {
617617
require.Nil(t, result)
618618
}
619619

620+
func TestUxMiddleware_Run_AzdextLocalErrorWithLinks(t *testing.T) {
621+
t.Parallel()
622+
console := mockinput.NewMockConsole()
623+
m := &UxMiddleware{
624+
options: &Options{},
625+
console: console,
626+
featuresManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()),
627+
}
628+
629+
localErr := &azdext.LocalError{
630+
Message: "Extension config missing",
631+
Code: "missing_config",
632+
Links: []errorhandler.ErrorLink{{
633+
URL: "https://aka.ms/azd-errors#missing-config",
634+
Title: "Missing config help",
635+
}},
636+
}
637+
638+
result, err := m.Run(t.Context(), func(_ context.Context) (*actions.ActionResult, error) {
639+
return nil, localErr
640+
})
641+
642+
require.Error(t, err)
643+
require.Nil(t, result)
644+
require.Contains(t, fmt.Sprint(console.Output()), "https://aka.ms/azd-errors#missing-config")
645+
require.NotContains(t, fmt.Sprint(console.Output()), "Suggestion:")
646+
}
647+
620648
func TestUxMiddleware_Run_AzdextLocalErrorWithoutSuggestion(t *testing.T) {
621649
t.Parallel()
622650
console := mockinput.NewMockConsole()

cli/azd/cmd/middleware/ux.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,15 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes
6868

6969
// Bridge extension errors (LocalError/ServiceError) with suggestions to rich UX.
7070
// Covers both CLI extension commands and gRPC service target errors.
71-
if suggestion := azdext.ErrorSuggestion(err); suggestion != "" {
71+
if suggestion := azdext.ErrorSuggestion(err); suggestion != "" || len(azdext.ErrorLinks(err)) > 0 {
7272
message := azdext.ErrorMessage(err)
7373
if message == "" {
7474
message = err.Error()
7575
}
7676
displayErr := &ux.ErrorWithSuggestion{
7777
Message: message,
7878
Suggestion: suggestion,
79+
Links: azdext.ErrorLinks(err),
7980
}
8081
m.console.Message(ctx, "")
8182
m.console.MessageUxItem(ctx, displayErr)

cli/azd/grpc/proto/errors.proto

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,28 @@ message ServiceErrorDetail {
2525

2626
// LocalErrorDetail contains structured error information for local/validation failures.
2727
// Used when ErrorOrigin is ERROR_ORIGIN_LOCAL.
28-
message LocalErrorDetail {
29-
string code = 1; // Extension-defined error code (e.g., "invalid_config")
30-
string category = 2; // Error category (e.g., "user", "validation", "dependency", "internal")
31-
}
32-
33-
// ExtensionError is a unified error message that can represent errors from different sources.
34-
// It provides structured error information for telemetry and error handling.
35-
message ExtensionError {
36-
reserved 1, 3; // Reserved for future use; do not reuse.
37-
string message = 2; // Human-readable error message
38-
ErrorOrigin origin = 4; // Where the error originated from
39-
string suggestion = 5; // Optional user-facing suggestion for resolving the error
40-
41-
// Source-specific structured details. Only one should be set based on origin.
42-
oneof source {
28+
message LocalErrorDetail {
29+
string code = 1; // Extension-defined error code (e.g., "invalid_config")
30+
string category = 2; // Error category (e.g., "user", "validation", "dependency", "internal")
31+
}
32+
33+
// ErrorLink contains a reference link with a URL and optional title.
34+
message ErrorLink {
35+
string url = 1; // Link target
36+
string title = 2; // Display text (falls back to the URL when empty)
37+
}
38+
39+
// ExtensionError is a unified error message that can represent errors from different sources.
40+
// It provides structured error information for telemetry and error handling.
41+
message ExtensionError {
42+
reserved 1, 3; // Reserved for future use; do not reuse.
43+
string message = 2; // Human-readable error message
44+
ErrorOrigin origin = 4; // Where the error originated from
45+
string suggestion = 5; // Optional user-facing suggestion for resolving the error
46+
repeated ErrorLink links = 6; // Optional reference links rendered alongside the suggestion
47+
48+
// Source-specific structured details. Only one should be set based on origin.
49+
oneof source {
4350
ServiceErrorDetail service_error = 10;
4451
LocalErrorDetail local_error = 11;
4552
// ToolErrorDetail tool_error = 12;

cli/azd/grpc/proto/event.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package azdext;
77
option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext";
88

99
import "models.proto";
10+
import "errors.proto";
1011

1112
// EventService defines methods for event subscription, invocation, and status updates.
1213
// Clients can subscribe to events and receive notifications via a bidirectional stream.
@@ -68,7 +69,10 @@ message ProjectHandlerStatus {
6869
// Status such as "running", "completed", "failed", etc.
6970
string status = 2;
7071
// Optional message providing further details.
72+
// For backward compatibility with older hosts, populate this even when error is set.
7173
string message = 3;
74+
// Optional structured error details (set when status is "failed").
75+
ExtensionError error = 4;
7276
}
7377

7478
// Client sends status updates for service events
@@ -80,5 +84,8 @@ message ServiceHandlerStatus {
8084
// Status such as "running", "completed", "failed", etc.
8185
string status = 3;
8286
// Optional message providing further details.
87+
// For backward compatibility with older hosts, populate this even when error is set.
8388
string message = 4;
89+
// Optional structured error details (set when status is "failed").
90+
ExtensionError error = 5;
8491
}

cli/azd/internal/grpcserver/event_service.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ func (s *eventService) createProjectEventHandler(
179179
}
180180

181181
if statusMsg.ProjectHandlerStatus.Status == "failed" {
182+
if extErr := azdext.UnwrapError(statusMsg.ProjectHandlerStatus.Error); extErr != nil {
183+
log.Printf(
184+
"extension %s project hook %s failed with structured error: %v",
185+
extension.Id,
186+
eventName,
187+
extErr,
188+
)
189+
return extErr
190+
}
182191
return fmt.Errorf(
183192
"extension %s project hook %s failed: %s",
184193
extension.Id,
@@ -297,6 +306,16 @@ func (s *eventService) createServiceEventHandler(
297306
}
298307

299308
if statusMsg.ServiceHandlerStatus.Status == "failed" {
309+
if extErr := azdext.UnwrapError(statusMsg.ServiceHandlerStatus.Error); extErr != nil {
310+
log.Printf(
311+
"extension %s service hook %s.%s failed with structured error: %v",
312+
extension.Id,
313+
args.Service.Name,
314+
eventName,
315+
extErr,
316+
)
317+
return extErr
318+
}
300319
return fmt.Errorf(
301320
"extension %s service hook %s.%s failed: %s",
302321
extension.Id,

0 commit comments

Comments
 (0)