-
Notifications
You must be signed in to change notification settings - Fork 341
Enhance GitHub API error handling #7922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rajeshkamal5050
merged 5 commits into
Azure:main
from
JeffreyCA:jeffreyca/improve-gh-errors
May 1, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8a38cf4
Enhance GitHub API error handling with structured ApiError type and a…
Copilot cbc30b6
Fix cspell
JeffreyCA 328148f
Address PR review feedback for GitHub API error handling
JeffreyCA 5a67833
Address feedback
JeffreyCA 20f15d5
Add test coverage and address review feedback
JeffreyCA File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package templates | ||
|
|
||
| import ( | ||
| "errors" | ||
|
|
||
| "github.com/azure/azure-dev/cli/azd/internal" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/tools/github" | ||
| ) | ||
|
|
||
| // withGitHubSuggestion wraps the supplied error in an *internal.ErrorWithSuggestion | ||
| // when the error is a recognized GitHub failure (typed *github.ApiError or | ||
| // *RepoNotAccessibleError). The wrapping carries actionable guidance inline | ||
| // in the error chain, which means: | ||
| // | ||
| // - The core CLI ErrorMiddleware renders it directly without consulting the | ||
| // YAML rules pipeline (ErrorMiddleware short-circuits on | ||
| // *ErrorWithSuggestion). | ||
| // - Callers that don't have access to the YAML pipeline (e.g., extensions | ||
| // receiving stringified errors over gRPC) still see the suggestion as part | ||
| // of the wrapped error, because *ApiError keeps formatting itself with | ||
| // status + message. | ||
| // | ||
| // Returns the original error unchanged when no specific suggestion applies, so | ||
| // other typed-error pipelines (auth, unknown gh failures) behave as before. | ||
| func withGitHubSuggestion(err error) error { | ||
| if err == nil { | ||
| return nil | ||
| } | ||
|
|
||
| if apiErr, ok := errors.AsType[*github.ApiError](err); ok { | ||
| if s := suggestionForApiError(apiErr); s != nil { | ||
| s.Err = err | ||
| return s | ||
| } | ||
| } | ||
|
|
||
| if repoErr, ok := errors.AsType[*RepoNotAccessibleError](err); ok { | ||
| s := suggestionForRepoNotAccessible(repoErr) | ||
| s.Err = err | ||
| return s | ||
| } | ||
|
|
||
| return err | ||
| } | ||
|
|
||
| func suggestionForApiError(apiErr *github.ApiError) *internal.ErrorWithSuggestion { | ||
| switch apiErr.Kind { | ||
| case github.KindSAMLBlocked: | ||
| return &internal.ErrorWithSuggestion{ | ||
| Message: "The GitHub organization that owns this repository requires SAML SSO " + | ||
| "authorization for your token before it can be used.", | ||
| Suggestion: "If you signed in with `gh auth login`, run `gh auth refresh` and " + | ||
| "complete the SSO authorization in the browser when prompted (use " + | ||
| "`gh auth refresh -h <host>` for non-default hosts). If you're using a " + | ||
| "personal access token, open your GitHub token settings, locate the " + | ||
| "token, click 'Configure SSO', and authorize the organization that owns " + | ||
| "this repository.", | ||
| Links: []errorhandler.ErrorLink{ | ||
| { | ||
| URL: "https://docs.github.com/enterprise-cloud@latest/authentication/" + | ||
| "authenticating-with-single-sign-on/" + | ||
| "about-authentication-with-single-sign-on", | ||
| Title: "About authentication with single sign-on", | ||
| }, | ||
| { | ||
| URL: "https://docs.github.com/enterprise-cloud@latest/authentication/" + | ||
| "authenticating-with-single-sign-on/" + | ||
| "authorizing-a-personal-access-token-for-use-with-single-sign-on", | ||
| Title: "Authorizing a personal access token for use with single sign-on", | ||
| }, | ||
| }, | ||
| } | ||
| case github.KindRateLimited: | ||
| return &internal.ErrorWithSuggestion{ | ||
| Message: "GitHub API rate limit exceeded.", | ||
| Suggestion: "Authenticated requests have a much higher limit than anonymous ones. " + | ||
| "Run `gh auth login` (or set GITHUB_TOKEN / GH_TOKEN) and retry. " + | ||
| "If you're already authenticated, wait for the rate-limit window to reset " + | ||
| "(typically up to one hour).", | ||
| Links: []errorhandler.ErrorLink{ | ||
| { | ||
| URL: "https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api", | ||
| Title: "Rate limits for the REST API", | ||
| }, | ||
| }, | ||
| } | ||
| case github.KindUnauthorized: | ||
| return &internal.ErrorWithSuggestion{ | ||
| Message: "GitHub rejected the request as unauthenticated (HTTP 401).", | ||
| Suggestion: "Run `gh auth login` to sign in, or refresh an expired token with " + | ||
| "`gh auth refresh`. If you're using GITHUB_TOKEN / GH_TOKEN, regenerate the " + | ||
| "token and ensure it has the required scopes.", | ||
| } | ||
| case github.KindForbidden: | ||
| return &internal.ErrorWithSuggestion{ | ||
| Message: "GitHub denied access to the requested resource (HTTP 403). The " + | ||
| "repository may be private, your token may be missing required scopes, or " + | ||
| "your account may not have permission.", | ||
| Suggestion: "Verify you can access the repository in a browser while signed in " + | ||
| "as the same GitHub account. If you're using a personal access token, ensure " + | ||
| "it includes the 'repo' scope. Run `gh auth status` to confirm which account " + | ||
| "gh is using.", | ||
| } | ||
| case github.KindServerError: | ||
| return &internal.ErrorWithSuggestion{ | ||
| Message: "GitHub returned a server error (HTTP 5xx). This usually indicates a " + | ||
| "transient issue on GitHub's side rather than a problem with your request.", | ||
| Suggestion: "Wait a few minutes and try again. If the problem persists, check " + | ||
| "https://www.githubstatus.com/ for ongoing incidents.", | ||
| Links: []errorhandler.ErrorLink{ | ||
| { | ||
| URL: "https://www.githubstatus.com/", | ||
| Title: "GitHub Status", | ||
| }, | ||
| }, | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func suggestionForRepoNotAccessible(_ *RepoNotAccessibleError) *internal.ErrorWithSuggestion { | ||
| // Leave Message empty so the renderer falls back to RepoNotAccessibleError.Error(), | ||
| // which is already user-friendly and includes the repo slug — avoids duplication. | ||
|
JeffreyCA marked this conversation as resolved.
|
||
| return &internal.ErrorWithSuggestion{ | ||
| Suggestion: "Confirm the repository URL is correct and that the active gh account " + | ||
| "can see it. Run `gh auth status` to check which account is active. For Enterprise " + | ||
| "Managed Users (EMU), make sure the active account is the EMU account that owns " + | ||
| "this repository — a github.com URL may need to target a different host.", | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package templates | ||
|
|
||
| import ( | ||
| "errors" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/azure/azure-dev/cli/azd/internal" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/tools/github" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // TestSuggestionForApiError_PerKind verifies that every classified | ||
| // ApiErrorKind that is meant to surface user guidance produces a non-nil | ||
| // *internal.ErrorWithSuggestion containing the substrings users rely on | ||
| // (the suggestion text and the relevant doc link). Kinds that intentionally | ||
| // return nil (NotFound, Other, Unknown) are also asserted to ensure we | ||
| // don't silently start emitting suggestions where none are expected. | ||
| func TestSuggestionForApiError_PerKind(t *testing.T) { | ||
| t.Parallel() | ||
| cases := []struct { | ||
| name string | ||
| kind github.ApiErrorKind | ||
| wantNil bool | ||
| wantSnippet string // substring required in the rendered suggestion text | ||
| wantLink string // substring required in at least one Links[].URL (empty = skip) | ||
| }{ | ||
| {"SAMLBlocked", github.KindSAMLBlocked, false, | ||
| "SAML SSO", "authenticating-with-single-sign-on"}, | ||
| {"RateLimited", github.KindRateLimited, false, | ||
| "rate limit", "rate-limits-for-the-rest-api"}, | ||
| {"Unauthorized", github.KindUnauthorized, false, | ||
| "gh auth login", ""}, | ||
| {"Forbidden", github.KindForbidden, false, | ||
| "gh auth status", ""}, | ||
| {"ServerError", github.KindServerError, false, | ||
| "server error", "githubstatus.com"}, | ||
| {"NotFound returns nil — RepoNotAccessibleError handles that path", github.KindNotFound, true, "", ""}, | ||
| {"Other returns nil — falls through to typed *ApiError.Error()", github.KindOther, true, "", ""}, | ||
| {"Unknown returns nil — surfaces underlying error", github.KindUnknown, true, "", ""}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| apiErr := &github.ApiError{ | ||
| URL: "https://api.github.com/repos/o/r", | ||
| Kind: tc.kind, | ||
| StatusCode: 500, // arbitrary, not asserted | ||
| } | ||
| got := suggestionForApiError(apiErr) | ||
| if tc.wantNil { | ||
| require.Nil(t, got, "expected nil suggestion for %s", tc.kind) | ||
| return | ||
| } | ||
| require.NotNil(t, got, "expected non-nil suggestion for %s", tc.kind) | ||
| combined := got.Message + " " + got.Suggestion | ||
| require.Contains(t, combined, tc.wantSnippet) | ||
| if tc.wantLink != "" { | ||
| var found bool | ||
| for _, l := range got.Links { | ||
| if strings.Contains(l.URL, tc.wantLink) { | ||
| found = true | ||
| break | ||
| } | ||
| } | ||
| require.True(t, found, "expected a Links[] URL containing %q, got %+v", tc.wantLink, got.Links) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestSuggestionForRepoNotAccessible verifies the dedicated | ||
| // RepoNotAccessibleError suggestion is emitted with EMU/private-repo guidance | ||
| // and a non-empty Suggestion field — Message is intentionally empty so the | ||
| // renderer falls back to RepoNotAccessibleError.Error() (which already | ||
| // contains the repo slug) and we don't duplicate the same text twice. | ||
| func TestSuggestionForRepoNotAccessible(t *testing.T) { | ||
| t.Parallel() | ||
| got := suggestionForRepoNotAccessible(&RepoNotAccessibleError{ | ||
| Hostname: "github.com", | ||
| RepoSlug: "owner/repo", | ||
| }) | ||
| require.NotNil(t, got) | ||
| require.Empty(t, got.Message, "Message must be empty so renderer uses RepoNotAccessibleError.Error()") | ||
| require.Contains(t, got.Suggestion, "gh auth status") | ||
| require.Contains(t, got.Suggestion, "EMU") | ||
| } | ||
|
|
||
| // TestWithGitHubSuggestion_Dispatch verifies the wrapper: | ||
| // - returns nil for nil input | ||
| // - wraps *github.ApiError with a classified suggestion (preserving the | ||
| // original error in ErrorWithSuggestion.Err so the chain still unwraps | ||
| // to the typed *ApiError) | ||
| // - wraps *RepoNotAccessibleError into a suggestion (always — never nil, | ||
| // because RepoNotAccessibleError is itself a strong signal) | ||
| // - returns the original error unchanged for unrecognized types | ||
| // - returns the original error unchanged when the typed error has a kind | ||
| // that intentionally has no suggestion (e.g., KindNotFound on its own — | ||
| // RepoNotAccessibleError is the surface for the "repo invisible" case) | ||
| func TestWithGitHubSuggestion_Dispatch(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| t.Run("nil in nil out", func(t *testing.T) { | ||
| t.Parallel() | ||
| require.NoError(t, withGitHubSuggestion(nil)) | ||
| }) | ||
|
|
||
| t.Run("ApiError with suggestion-bearing kind is wrapped", func(t *testing.T) { | ||
| t.Parallel() | ||
| original := &github.ApiError{ | ||
| URL: "https://api.github.com/repos/o/r/branches/main", | ||
| Kind: github.KindSAMLBlocked, | ||
| StatusCode: 403, | ||
| } | ||
| wrapped := withGitHubSuggestion(original) | ||
| ews, ok := errors.AsType[*internal.ErrorWithSuggestion](wrapped) | ||
| require.True(t, ok, "expected *internal.ErrorWithSuggestion, got %T", wrapped) | ||
| // The ErrorWithSuggestion wraps the original so error chain still | ||
| // unwraps to the typed ApiError for downstream consumers. | ||
| got, ok := errors.AsType[*github.ApiError](ews) | ||
| require.True(t, ok, "ErrorWithSuggestion must preserve *ApiError in chain") | ||
| require.Same(t, original, got) | ||
| }) | ||
|
|
||
| t.Run("ApiError without suggestion (KindNotFound) returned unchanged", func(t *testing.T) { | ||
| t.Parallel() | ||
| original := &github.ApiError{ | ||
| URL: "https://api.github.com/repos/o/r/branches/main", | ||
| Kind: github.KindNotFound, | ||
| StatusCode: 404, | ||
| } | ||
| got := withGitHubSuggestion(original) | ||
| require.Same(t, original, got, "no suggestion → return original error untouched") | ||
| }) | ||
|
|
||
| t.Run("RepoNotAccessibleError is always wrapped", func(t *testing.T) { | ||
| t.Parallel() | ||
| original := &RepoNotAccessibleError{Hostname: "github.com", RepoSlug: "owner/repo"} | ||
| wrapped := withGitHubSuggestion(original) | ||
| ews, ok := errors.AsType[*internal.ErrorWithSuggestion](wrapped) | ||
| require.True(t, ok) | ||
| // Chain still unwraps to the typed RepoNotAccessibleError. | ||
| got, ok := errors.AsType[*RepoNotAccessibleError](ews) | ||
| require.True(t, ok) | ||
| require.Same(t, original, got) | ||
| }) | ||
|
|
||
| t.Run("unrecognized error returned unchanged", func(t *testing.T) { | ||
| t.Parallel() | ||
| original := errors.New("some random error") | ||
| got := withGitHubSuggestion(original) | ||
| require.Same(t, original, got) | ||
| }) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.