Skip to content

Commit c2a21fc

Browse files
JeffreyCACopilot
andauthored
Unify host gRPC error transport with a structured ActionableErrorDetail (#7919)
* Unify host gRPC error transport with structured ActionableErrorDetail Replace the ad-hoc "<err>\n<suggestion>" concatenation in the host gRPC error path with a structured proto detail, and consolidate the helpers that serialize/deserialize host errors so suggestion and links travel as typed data instead of being parsed out of the message string. - Add ActionableErrorDetail { suggestion, links } proto (host -> extension) - Rename wrapErrorWithSuggestion -> mapHostError; statusMessage no longer concatenates the suggestion into status.Message - Remove duplicated grpcStatusFromError / wrapErrorLinks helpers in internal/grpcserver in favor of exported azdext counterparts - Factor WrapError through populateExtensionErrorFromStatus; drop the extErr.Message == err.Error() heuristic - Extend ErrorSuggestion / ErrorMessage / ErrorLinks to read host-attached ActionableErrorDetail so links survive the wire Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0e46bd6 commit c2a21fc

12 files changed

Lines changed: 671 additions & 185 deletions

File tree

cli/azd/grpc/proto/errors.proto

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,25 @@ message ErrorLink {
3636
string title = 2; // Display text (falls back to the URL when empty)
3737
}
3838

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.
39+
// ActionableErrorDetail carries user-facing remediation metadata in gRPC status details.
40+
// It is used for host-originated errors so clients can preserve suggestions and links
41+
// without parsing status message text.
42+
//
43+
// Direction: host -> extension only. Extensions returning errors to the host should use
44+
// LocalError / ServiceError (transported via ExtensionError) instead.
45+
//
46+
// The user-facing error message lives in google.rpc.Status.message; this detail does not
47+
// duplicate it.
48+
message ActionableErrorDetail {
49+
string suggestion = 1; // Optional user-facing suggestion for resolving the error
50+
repeated ErrorLink links = 2; // Optional reference links rendered alongside the suggestion
51+
}
52+
53+
// ExtensionError is a unified error message that can represent errors from different sources.
54+
// It provides structured error information for telemetry and error handling.
55+
//
56+
// Direction: extension -> host only. Counterpart of host's ActionableErrorDetail, which carries
57+
// host-originated remediation metadata in the opposite direction.
4158
message ExtensionError {
4259
reserved 1, 3; // Reserved for future use; do not reuse.
4360
string message = 2; // Human-readable error message
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package grpcserver
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"log"
10+
11+
"github.com/azure/azure-dev/cli/azd/internal"
12+
"github.com/azure/azure-dev/cli/azd/pkg/auth"
13+
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
14+
"google.golang.org/genproto/googleapis/rpc/errdetails"
15+
"google.golang.org/grpc/codes"
16+
"google.golang.org/grpc/status"
17+
)
18+
19+
// mapHostError serializes a host-originated Go error into a gRPC status error for transport
20+
// to extensions.
21+
//
22+
// This is the gRPC serialization boundary: the original error chain (e.g. *auth.AuthFailedError)
23+
// is intentionally not preserved across the wire — only the structured details defined here.
24+
//
25+
// Auth-related errors are reported with codes.Unauthenticated and an azd.auth ErrorInfo detail
26+
// (preserving AADSTS<code> reasons from Entra). ErrorWithSuggestion errors carry an
27+
// ActionableErrorDetail so consumers receive suggestion + links structurally.
28+
//
29+
// status.Message is always err.Error() (or ErrorWithSuggestion.Message when set). Suggestion
30+
// text is never concatenated into status.Message; consumers must read ActionableErrorDetail
31+
// for remediation guidance.
32+
func mapHostError(err error) error {
33+
if err == nil {
34+
return nil
35+
}
36+
37+
suggestionErr, hasSuggestion := errors.AsType[*internal.ErrorWithSuggestion](err)
38+
isAuthErr := isAuthError(err)
39+
if !hasSuggestion && !isAuthErr {
40+
return err
41+
}
42+
43+
code := codes.Unknown
44+
if st, ok := azdext.GRPCStatusFromError(err); ok {
45+
code = st.Code()
46+
}
47+
if isAuthErr {
48+
code = codes.Unauthenticated
49+
}
50+
51+
st := status.New(code, statusMessage(err, suggestionErr))
52+
if isAuthErr {
53+
st = withAuthErrorInfo(st, err)
54+
}
55+
if hasSuggestion {
56+
st = withActionableErrorDetail(st, suggestionErr)
57+
}
58+
59+
return st.Err()
60+
}
61+
62+
// statusMessage returns the user-facing message that should populate status.Message.
63+
// When the source is an ErrorWithSuggestion with an explicit Message, that wins. Otherwise,
64+
// if err is already a gRPC status error, use its Message (avoids nesting "rpc error: ..."
65+
// prefixes in the new status). Falls back to err.Error(). Suggestion text is never appended.
66+
func statusMessage(err error, suggestionErr *internal.ErrorWithSuggestion) string {
67+
if suggestionErr != nil && suggestionErr.Message != "" {
68+
return suggestionErr.Message
69+
}
70+
if st, ok := azdext.GRPCStatusFromError(err); ok {
71+
return st.Message()
72+
}
73+
return err.Error()
74+
}
75+
76+
// isAuthError reports whether err's chain contains a known auth-failure type that should be
77+
// surfaced over gRPC as codes.Unauthenticated.
78+
func isAuthError(err error) bool {
79+
if errors.Is(err, auth.ErrNoCurrentUser) {
80+
return true
81+
}
82+
if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
83+
return true
84+
}
85+
if _, ok := errors.AsType[*auth.AuthFailedError](err); ok {
86+
return true
87+
}
88+
return false
89+
}
90+
91+
func withAuthErrorInfo(st *status.Status, err error) *status.Status {
92+
reason := grpcAuthReason(err)
93+
if reason == "" {
94+
return st
95+
}
96+
97+
withDetails, detailErr := st.WithDetails(&errdetails.ErrorInfo{
98+
Reason: reason,
99+
Domain: azdext.AuthErrorDomain,
100+
})
101+
if detailErr != nil {
102+
log.Printf("failed to attach auth ErrorInfo to gRPC status: %v", detailErr)
103+
return st
104+
}
105+
106+
return withDetails
107+
}
108+
109+
func withActionableErrorDetail(st *status.Status, err *internal.ErrorWithSuggestion) *status.Status {
110+
if err.Suggestion == "" && len(err.Links) == 0 {
111+
return st
112+
}
113+
114+
withDetails, detailErr := st.WithDetails(&azdext.ActionableErrorDetail{
115+
Suggestion: err.Suggestion,
116+
Links: azdext.WrapErrorLinks(err.Links),
117+
})
118+
if detailErr != nil {
119+
log.Printf("failed to attach ActionableErrorDetail to gRPC status: %v", detailErr)
120+
return st
121+
}
122+
123+
return withDetails
124+
}
125+
126+
func grpcAuthReason(err error) string {
127+
if errors.Is(err, auth.ErrNoCurrentUser) {
128+
return azdext.AuthErrorReasonNotLoggedIn
129+
}
130+
131+
// Pass through the originating AAD error code (e.g., "AADSTS530084") when available.
132+
// This preserves Entra's own semantics rather than redefining them on azd's side.
133+
if authFailed, ok := errors.AsType[*auth.AuthFailedError](err); ok {
134+
if authFailed.Parsed != nil && len(authFailed.Parsed.ErrorCodes) > 0 {
135+
return fmt.Sprintf("AADSTS%d", authFailed.Parsed.ErrorCodes[0])
136+
}
137+
}
138+
139+
if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
140+
return azdext.AuthErrorReasonLoginRequired
141+
}
142+
143+
return ""
144+
}

cli/azd/internal/grpcserver/prompt_service_test.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,9 +665,15 @@ func Test_PromptService_PromptSubscription_ErrorWithSuggestion(t *testing.T) {
665665
})
666666

667667
require.Error(t, err)
668-
// Verify that the suggestion text is included in the error message (wrapped by interceptor)
669-
require.Contains(t, err.Error(), "azd auth login")
668+
// Status message carries only the underlying error; suggestion travels via ActionableErrorDetail.
670669
require.Contains(t, err.Error(), "AADSTS70043")
670+
require.NotContains(t, err.Error(), "azd auth login",
671+
"suggestion text must not be concatenated into status.Message")
672+
st, ok := status.FromError(err)
673+
require.True(t, ok)
674+
actionable := azdext.ActionableErrorDetailFromStatus(st)
675+
require.NotNil(t, actionable)
676+
require.Contains(t, actionable.GetSuggestion(), "azd auth login")
671677
mockPrompter.AssertExpectations(t)
672678
}
673679

@@ -697,9 +703,15 @@ func Test_PromptService_PromptResourceGroup_ErrorWithSuggestion(t *testing.T) {
697703
})
698704

699705
require.Error(t, err)
700-
// Verify that the suggestion text is included in the error message (wrapped by interceptor)
701-
require.Contains(t, err.Error(), "azd auth login")
706+
// Status message carries only the underlying error; suggestion travels via ActionableErrorDetail.
702707
require.Contains(t, err.Error(), "AADSTS70043")
708+
require.NotContains(t, err.Error(), "azd auth login",
709+
"suggestion text must not be concatenated into status.Message")
710+
st, ok := status.FromError(err)
711+
require.True(t, ok)
712+
actionable := azdext.ActionableErrorDetailFromStatus(st)
713+
require.NotNil(t, actionable)
714+
require.Contains(t, actionable.GetSuggestion(), "azd auth login")
703715
mockPrompter.AssertExpectations(t)
704716
}
705717

cli/azd/internal/grpcserver/server.go

Lines changed: 5 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,12 @@ package grpcserver
66
import (
77
"context"
88
"crypto/rand"
9-
"errors"
109
"fmt"
1110
"log"
1211
"net"
1312

14-
"github.com/azure/azure-dev/cli/azd/internal"
15-
"github.com/azure/azure-dev/cli/azd/pkg/auth"
1613
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
1714
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
18-
"google.golang.org/genproto/googleapis/rpc/errdetails"
1915
"google.golang.org/grpc"
2016
"google.golang.org/grpc/codes"
2117
"google.golang.org/grpc/metadata"
@@ -159,9 +155,9 @@ func (s *Server) Stop() error {
159155
return nil
160156
}
161157

162-
// errorWrappingInterceptor wraps ErrorWithSuggestion errors to include their suggestion text
163-
// in the error message. This ensures that helpful suggestions (like "run azd auth login")
164-
// are preserved when errors are transmitted over gRPC, where only the error message string is sent.
158+
// errorWrappingInterceptor maps host errors into gRPC status errors with structured details
159+
// (auth ErrorInfo, ActionableErrorDetail) so extensions can preserve actionable guidance
160+
// without parsing status message text. See [mapHostError] for the contract.
165161
func (s *Server) errorWrappingInterceptor() grpc.UnaryServerInterceptor {
166162
return func(
167163
ctx context.Context,
@@ -171,15 +167,13 @@ func (s *Server) errorWrappingInterceptor() grpc.UnaryServerInterceptor {
171167
) (any, error) {
172168
resp, err := handler(ctx, req)
173169
if err != nil {
174-
err = wrapErrorWithSuggestion(err)
170+
err = mapHostError(err)
175171
}
176172
return resp, err
177173
}
178174
}
179175

180176
// errorWrappingStreamInterceptor is the streaming counterpart of errorWrappingInterceptor.
181-
// It wraps ErrorWithSuggestion errors from stream handlers so that actionable suggestions
182-
// are preserved in gRPC stream error responses.
183177
func (s *Server) errorWrappingStreamInterceptor() grpc.StreamServerInterceptor {
184178
return func(
185179
srv any,
@@ -189,7 +183,7 @@ func (s *Server) errorWrappingStreamInterceptor() grpc.StreamServerInterceptor {
189183
) error {
190184
err := handler(srv, ss)
191185
if err != nil {
192-
err = wrapErrorWithSuggestion(err)
186+
err = mapHostError(err)
193187
}
194188
return err
195189
}
@@ -268,89 +262,6 @@ func (s *authenticatedStream) Context() context.Context {
268262
return s.ctx
269263
}
270264

271-
// wrapErrorWithSuggestion checks if the error contains an ErrorWithSuggestion and if so,
272-
// returns a new error that includes the suggestion text in the error message.
273-
// This ensures that helpful suggestions (like "run azd auth login") are preserved
274-
// when errors are transmitted over gRPC, where only the error message string is sent.
275-
//
276-
// Auth-related errors are returned with codes.Unauthenticated and a structured
277-
// ErrorInfo reason so extensions can reliably classify them as auth failures
278-
// while still distinguishing the remediation path.
279-
func wrapErrorWithSuggestion(err error) error {
280-
if err == nil {
281-
return nil
282-
}
283-
284-
isAuthErr := isAuthError(err)
285-
286-
if suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok {
287-
msg := fmt.Sprintf("%s\n%s", err.Error(), suggestionErr.Suggestion)
288-
if isAuthErr {
289-
return grpcAuthStatus(err, msg)
290-
}
291-
return fmt.Errorf("%w\n%s", err, suggestionErr.Suggestion)
292-
}
293-
294-
if isAuthErr {
295-
return grpcAuthStatus(err, err.Error())
296-
}
297-
298-
return err
299-
}
300-
301-
// isAuthError reports whether err's chain contains a known auth-failure type that should be
302-
// surfaced over gRPC as codes.Unauthenticated.
303-
func isAuthError(err error) bool {
304-
if errors.Is(err, auth.ErrNoCurrentUser) {
305-
return true
306-
}
307-
if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
308-
return true
309-
}
310-
if _, ok := errors.AsType[*auth.AuthFailedError](err); ok {
311-
return true
312-
}
313-
return false
314-
}
315-
316-
func grpcAuthStatus(err error, msg string) error {
317-
st := status.New(codes.Unauthenticated, msg)
318-
reason := grpcAuthReason(err)
319-
if reason == "" {
320-
return st.Err()
321-
}
322-
323-
withDetails, detailErr := st.WithDetails(&errdetails.ErrorInfo{
324-
Reason: reason,
325-
Domain: azdext.AuthErrorDomain,
326-
})
327-
if detailErr != nil {
328-
return st.Err()
329-
}
330-
331-
return withDetails.Err()
332-
}
333-
334-
func grpcAuthReason(err error) string {
335-
if errors.Is(err, auth.ErrNoCurrentUser) {
336-
return azdext.AuthErrorReasonNotLoggedIn
337-
}
338-
339-
// Pass through the originating AAD error code (e.g., "AADSTS530084") when available.
340-
// This preserves Entra's own semantics rather than redefining them on azd's side.
341-
if authFailed, ok := errors.AsType[*auth.AuthFailedError](err); ok {
342-
if authFailed.Parsed != nil && len(authFailed.Parsed.ErrorCodes) > 0 {
343-
return fmt.Sprintf("AADSTS%d", authFailed.Parsed.ErrorCodes[0])
344-
}
345-
}
346-
347-
if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
348-
return azdext.AuthErrorReasonLoginRequired
349-
}
350-
351-
return ""
352-
}
353-
354265
func generateSigningKey() ([]byte, error) {
355266
bytes := make([]byte, 32) // 256-bit HMAC signing key
356267
if _, err := rand.Read(bytes); err != nil {

0 commit comments

Comments
 (0)