<!-- PR description--> Builds on top of earlier PR #4417 to skip graph API call if the token has already expired. This is a performance optimization. --- #### Does this PR need a docs update or release note? - [x] ✅ Yes, it's included - [ ] 🕐 Yes, but in a later PR - [ ] ⛔ No #### Type of change <!--- Please check the type of change your PR introduces: ---> - [ ] 🌻 Feature - [ ] 🐛 Bugfix - [ ] 🗺️ Documentation - [ ] 🤖 Supportability/Tests - [ ] 💻 CI/Deployment - [ ] 🧹 Tech Debt/Cleanup - [x] Performance Opt #### Issue(s) <!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. --> * internal #### Test Plan <!-- How will this be tested prior to merging.--> - [ ] 💪 Manual - [x] ⚡ Unit test - [x] 💚 E2E
515 lines
15 KiB
Go
515 lines
15 KiB
Go
package graph
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/alcionai/clues"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models/odataerrors"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
|
"github.com/alcionai/corso/src/pkg/fault"
|
|
"github.com/alcionai/corso/src/pkg/filters"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error Interpretation Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type errorCode string
|
|
|
|
const (
|
|
applicationThrottled errorCode = "ApplicationThrottled"
|
|
// this auth error is a catch-all used by graph in a variety of cases:
|
|
// users without licenses, bad jwts, missing account permissions, etc.
|
|
AuthenticationError errorCode = "AuthenticationError"
|
|
// cannotOpenFileAttachment happen when an attachment is
|
|
// inaccessible. The error message is usually "OLE conversion
|
|
// failed for an attachment."
|
|
cannotOpenFileAttachment errorCode = "ErrorCannotOpenFileAttachment"
|
|
emailFolderNotFound errorCode = "ErrorSyncFolderNotFound"
|
|
ErrorAccessDenied errorCode = "ErrorAccessDenied"
|
|
errorItemNotFound errorCode = "ErrorItemNotFound"
|
|
// This error occurs when an attempt is made to create a folder that has
|
|
// the same name as another folder in the same parent. Such duplicate folder
|
|
// names are not allowed by graph.
|
|
folderExists errorCode = "ErrorFolderExists"
|
|
// Some datacenters are returning this when we try to get the inbox of a user
|
|
// that doesn't exist.
|
|
invalidUser errorCode = "ErrorInvalidUser"
|
|
itemNotFound errorCode = "itemNotFound"
|
|
MailboxNotEnabledForRESTAPI errorCode = "MailboxNotEnabledForRESTAPI"
|
|
malwareDetected errorCode = "malwareDetected"
|
|
// nameAlreadyExists occurs when a request with
|
|
// @microsoft.graph.conflictBehavior=fail finds a conflicting file.
|
|
nameAlreadyExists errorCode = "nameAlreadyExists"
|
|
noResolvedUsers errorCode = "noResolvedUsers"
|
|
QuotaExceeded errorCode = "ErrorQuotaExceeded"
|
|
RequestResourceNotFound errorCode = "Request_ResourceNotFound"
|
|
// Returned when we try to get the inbox of a user that doesn't exist.
|
|
ResourceNotFound errorCode = "ResourceNotFound"
|
|
resyncRequired errorCode = "ResyncRequired"
|
|
syncFolderNotFound errorCode = "ErrorSyncFolderNotFound"
|
|
syncStateInvalid errorCode = "SyncStateInvalid"
|
|
syncStateNotFound errorCode = "SyncStateNotFound"
|
|
)
|
|
|
|
type errorMessage string
|
|
|
|
const (
|
|
IOErrDuringRead errorMessage = "IO error during request payload read"
|
|
MysiteURLNotFound errorMessage = "unable to retrieve user's mysite url"
|
|
MysiteNotFound errorMessage = "user's mysite not found"
|
|
NoSPLicense errorMessage = "Tenant does not have a SPO license"
|
|
parameterDeltaTokenNotSupported errorMessage = "Parameter 'DeltaToken' not supported for this request"
|
|
usersCannotBeResolved errorMessage = "One or more users could not be resolved"
|
|
requestedSiteCouldNotBeFound errorMessage = "Requested site could not be found"
|
|
)
|
|
|
|
const (
|
|
LabelsMalware = "malware_detected"
|
|
LabelsMysiteNotFound = "mysite_not_found"
|
|
LabelsNoSharePointLicense = "no_sharepoint_license"
|
|
|
|
// LabelsSkippable is used to determine if an error is skippable
|
|
LabelsSkippable = "skippable_errors"
|
|
)
|
|
|
|
var (
|
|
// ErrApplicationThrottled occurs if throttling retries are exhausted and completely
|
|
// fails out.
|
|
ErrApplicationThrottled = clues.New("application throttled")
|
|
|
|
// The folder or item was deleted between the time we identified
|
|
// it and when we tried to fetch data for it.
|
|
ErrDeletedInFlight = clues.New("deleted in flight")
|
|
|
|
// Delta tokens can be desycned or expired. In either case, the token
|
|
// becomes invalid, and cannot be used again.
|
|
// https://learn.microsoft.com/en-us/graph/errors#code-property
|
|
ErrInvalidDelta = clues.New("invalid delta token")
|
|
|
|
// Not all systems support delta queries. This must be handled separately
|
|
// from invalid delta token cases.
|
|
ErrDeltaNotSupported = clues.New("delta not supported")
|
|
|
|
// ErrItemAlreadyExistsConflict denotes that a post or put attempted to create
|
|
// an item which already exists by some unique identifier. The identifier is
|
|
// not always the id. For example, in onedrive, this error can be produced
|
|
// when filenames collide in a @microsoft.graph.conflictBehavior=fail request.
|
|
ErrItemAlreadyExistsConflict = clues.New("item already exists")
|
|
|
|
// ErrMultipleResultsMatchIdentifier describes a situation where we're doing a lookup
|
|
// in some way other than by canonical url ID (ex: filtering, searching, etc).
|
|
// This error should only be returned if a unique result is an expected constraint
|
|
// of the call results. If it's possible to opportunistically select one of the many
|
|
// replies, no error should get returned.
|
|
ErrMultipleResultsMatchIdentifier = clues.New("multiple results match the identifier")
|
|
|
|
// ErrServiceNotEnabled identifies that a resource owner does not have
|
|
// access to a given service.
|
|
ErrServiceNotEnabled = clues.New("service is not enabled for that resource owner")
|
|
|
|
// Timeout errors are identified for tracking the need to retry calls.
|
|
// Other delay errors, like throttling, are already handled by the
|
|
// graph client's built-in retries.
|
|
// https://github.com/microsoftgraph/msgraph-sdk-go/issues/302
|
|
ErrTimeout = clues.New("communication timeout")
|
|
|
|
ErrResourceOwnerNotFound = clues.New("resource owner not found in tenant")
|
|
|
|
ErrTokenExpired = clues.New("jwt token expired")
|
|
)
|
|
|
|
func IsErrApplicationThrottled(err error) bool {
|
|
return errors.Is(err, ErrApplicationThrottled) ||
|
|
hasErrorCode(err, applicationThrottled)
|
|
}
|
|
|
|
func IsErrAuthenticationError(err error) bool {
|
|
return hasErrorCode(err, AuthenticationError)
|
|
}
|
|
|
|
func IsErrDeletedInFlight(err error) bool {
|
|
if errors.Is(err, ErrDeletedInFlight) {
|
|
return true
|
|
}
|
|
|
|
if hasErrorCode(
|
|
err,
|
|
errorItemNotFound,
|
|
itemNotFound,
|
|
syncFolderNotFound) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func IsErrItemNotFound(err error) bool {
|
|
return hasErrorCode(err, itemNotFound)
|
|
}
|
|
|
|
func IsErrInvalidDelta(err error) bool {
|
|
return errors.Is(err, ErrInvalidDelta) ||
|
|
hasErrorCode(err, syncStateNotFound, resyncRequired, syncStateInvalid)
|
|
}
|
|
|
|
func IsErrDeltaNotSupported(err error) bool {
|
|
return errors.Is(err, ErrDeltaNotSupported) ||
|
|
hasErrorMessage(err, parameterDeltaTokenNotSupported)
|
|
}
|
|
|
|
func IsErrQuotaExceeded(err error) bool {
|
|
return hasErrorCode(err, QuotaExceeded)
|
|
}
|
|
|
|
func IsErrExchangeMailFolderNotFound(err error) bool {
|
|
// Not sure if we can actually see a resourceNotFound error here. I've only
|
|
// seen the latter two.
|
|
return hasErrorCode(err, ResourceNotFound, errorItemNotFound, MailboxNotEnabledForRESTAPI)
|
|
}
|
|
|
|
func IsErrUserNotFound(err error) bool {
|
|
if hasErrorCode(err, RequestResourceNotFound, invalidUser) {
|
|
return true
|
|
}
|
|
|
|
if hasErrorCode(err, ResourceNotFound) {
|
|
var odErr odataerrors.ODataErrorable
|
|
if !errors.As(err, &odErr) {
|
|
return false
|
|
}
|
|
|
|
mainMsg, _, _ := errData(odErr)
|
|
|
|
return strings.Contains(strings.ToLower(mainMsg), "user")
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func IsErrCannotOpenFileAttachment(err error) bool {
|
|
return hasErrorCode(err, cannotOpenFileAttachment)
|
|
}
|
|
|
|
func IsErrAccessDenied(err error) bool {
|
|
return hasErrorCode(err, ErrorAccessDenied) ||
|
|
clues.HasLabel(err, LabelStatus(http.StatusForbidden))
|
|
}
|
|
|
|
func IsErrTimeout(err error) bool {
|
|
switch err := err.(type) {
|
|
case *url.Error:
|
|
return err.Timeout()
|
|
}
|
|
|
|
return errors.Is(err, ErrTimeout) ||
|
|
errors.Is(err, context.Canceled) ||
|
|
errors.Is(err, context.DeadlineExceeded) ||
|
|
errors.Is(err, http.ErrHandlerTimeout) ||
|
|
os.IsTimeout(err)
|
|
}
|
|
|
|
func IsErrConnectionReset(err error) bool {
|
|
return errors.Is(err, syscall.ECONNRESET)
|
|
}
|
|
|
|
func IsErrUnauthorized(err error) bool {
|
|
// TODO: refine this investigation. We don't currently know if
|
|
// a specific item download url expired, or if the full connection
|
|
// auth expired.
|
|
return clues.HasLabel(err, LabelStatus(http.StatusUnauthorized)) ||
|
|
errors.Is(err, ErrTokenExpired)
|
|
}
|
|
|
|
func IsErrItemAlreadyExistsConflict(err error) bool {
|
|
return errors.Is(err, ErrItemAlreadyExistsConflict) ||
|
|
hasErrorCode(err, nameAlreadyExists)
|
|
}
|
|
|
|
// LabelStatus transforms the provided statusCode into
|
|
// a standard label that can be attached to a clues error
|
|
// and later reviewed when checking error statuses.
|
|
func LabelStatus(statusCode int) string {
|
|
return fmt.Sprintf("status_code_%d", statusCode)
|
|
}
|
|
|
|
// IsMalware is true if the graphAPI returns a "malware detected" error code.
|
|
func IsMalware(err error) bool {
|
|
return hasErrorCode(err, malwareDetected)
|
|
}
|
|
|
|
func IsMalwareResp(ctx context.Context, resp *http.Response) bool {
|
|
// https://learn.microsoft.com/en-us/openspecs/sharepoint_protocols/ms-wsshp/ba4ee7a8-704c-4e9c-ab14-fa44c574bdf4
|
|
// https://learn.microsoft.com/en-us/openspecs/sharepoint_protocols/ms-wdvmoduu/6fa6d4a9-ac18-4cd7-b696-8a3b14a98291
|
|
return resp != nil &&
|
|
len(resp.Header) > 0 &&
|
|
resp.Header.Get("X-Virus-Infected") == "true"
|
|
}
|
|
|
|
func IsErrFolderExists(err error) bool {
|
|
return hasErrorCode(err, folderExists)
|
|
}
|
|
|
|
func IsErrUsersCannotBeResolved(err error) bool {
|
|
return hasErrorCode(err, noResolvedUsers) || hasErrorMessage(err, usersCannotBeResolved)
|
|
}
|
|
|
|
func IsErrSiteNotFound(err error) bool {
|
|
return hasErrorMessage(err, requestedSiteCouldNotBeFound)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// error parsers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func hasErrorCode(err error, codes ...errorCode) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var oDataError odataerrors.ODataErrorable
|
|
if !errors.As(err, &oDataError) {
|
|
return false
|
|
}
|
|
|
|
code, ok := ptr.ValOK(oDataError.GetErrorEscaped().GetCode())
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
cs := make([]string, len(codes))
|
|
for i, c := range codes {
|
|
cs[i] = string(c)
|
|
}
|
|
|
|
return filters.Equal(cs).Compare(code)
|
|
}
|
|
|
|
// only use this as a last resort. Prefer the code or statuscode if possible.
|
|
func hasErrorMessage(err error, msgs ...errorMessage) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var oDataError odataerrors.ODataErrorable
|
|
if !errors.As(err, &oDataError) {
|
|
return false
|
|
}
|
|
|
|
msg, ok := ptr.ValOK(oDataError.GetErrorEscaped().GetMessage())
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
cs := make([]string, len(msgs))
|
|
for i, c := range msgs {
|
|
cs[i] = string(c)
|
|
}
|
|
|
|
return filters.In(cs).Compare(msg)
|
|
}
|
|
|
|
// Wrap is a helper function that extracts ODataError metadata from
|
|
// the error. If the error is not an ODataError type, returns the error.
|
|
func Wrap(ctx context.Context, e error, msg string) *clues.Err {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
var oDataError odataerrors.ODataErrorable
|
|
if !errors.As(e, &oDataError) {
|
|
return clues.Wrap(e, msg).WithClues(ctx).WithTrace(1)
|
|
}
|
|
|
|
mainMsg, data, innerMsg := errData(oDataError)
|
|
|
|
if len(mainMsg) > 0 {
|
|
e = clues.Stack(e, clues.New(mainMsg))
|
|
}
|
|
|
|
ce := clues.Wrap(e, msg).WithClues(ctx).With(data...).WithTrace(1)
|
|
|
|
return setLabels(ce, innerMsg)
|
|
}
|
|
|
|
// Stack is a helper function that extracts ODataError metadata from
|
|
// the error. If the error is not an ODataError type, returns the error.
|
|
func Stack(ctx context.Context, e error) *clues.Err {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
var oDataError *odataerrors.ODataError
|
|
if !errors.As(e, &oDataError) {
|
|
return clues.Stack(e).WithClues(ctx).WithTrace(1)
|
|
}
|
|
|
|
mainMsg, data, innerMsg := errData(oDataError)
|
|
|
|
if len(mainMsg) > 0 {
|
|
e = clues.Stack(e, clues.New(mainMsg))
|
|
}
|
|
|
|
ce := clues.Stack(e).WithClues(ctx).With(data...).WithTrace(1)
|
|
|
|
return setLabels(ce, innerMsg)
|
|
}
|
|
|
|
// stackReq is a helper function that extracts ODataError metadata from
|
|
// the error, plus http req/resp data. If the error is not an ODataError
|
|
// type, returns the error with only the req/resp values.
|
|
func stackReq(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
resp *http.Response,
|
|
e error,
|
|
) *clues.Err {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
se := Stack(ctx, e).
|
|
WithMap(reqData(req)).
|
|
WithMap(respData(resp))
|
|
|
|
return se
|
|
}
|
|
|
|
// Checks for the following conditions and labels the error accordingly:
|
|
// * mysiteNotFound | mysiteURLNotFound
|
|
// * malware
|
|
func setLabels(err *clues.Err, msg string) *clues.Err {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
f := filters.Contains([]string{msg})
|
|
|
|
if f.Compare(string(MysiteNotFound)) ||
|
|
f.Compare(string(MysiteURLNotFound)) {
|
|
err = err.Label(LabelsMysiteNotFound)
|
|
}
|
|
|
|
if f.Compare(string(NoSPLicense)) {
|
|
err = err.Label(LabelsNoSharePointLicense)
|
|
}
|
|
|
|
if IsMalware(err) {
|
|
err = err.Label(LabelsMalware)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func errData(err odataerrors.ODataErrorable) (string, []any, string) {
|
|
data := make([]any, 0)
|
|
|
|
// Get MainError
|
|
mainErr := err.GetErrorEscaped()
|
|
mainMsg := ptr.Val(mainErr.GetMessage())
|
|
|
|
data = appendIf(data, "odataerror_code", mainErr.GetCode())
|
|
data = appendIf(data, "odataerror_message", mainErr.GetMessage())
|
|
data = appendIf(data, "odataerror_target", mainErr.GetTarget())
|
|
msgConcat := ptr.Val(mainErr.GetMessage()) + ptr.Val(mainErr.GetCode())
|
|
|
|
for i, d := range mainErr.GetDetails() {
|
|
pfx := fmt.Sprintf("odataerror_details_%d_", i)
|
|
data = appendIf(data, pfx+"code", d.GetCode())
|
|
data = appendIf(data, pfx+"message", d.GetMessage())
|
|
data = appendIf(data, pfx+"target", d.GetTarget())
|
|
msgConcat += ptr.Val(d.GetMessage())
|
|
}
|
|
|
|
inner := mainErr.GetInnerError()
|
|
if inner != nil {
|
|
data = appendIf(data, "odataerror_inner_cli_req_id", inner.GetClientRequestId())
|
|
data = appendIf(data, "odataerror_inner_req_id", inner.GetRequestId())
|
|
}
|
|
|
|
return mainMsg, data, strings.ToLower(msgConcat)
|
|
}
|
|
|
|
func reqData(req *http.Request) map[string]any {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
|
|
r := map[string]any{}
|
|
r["req_method"] = req.Method
|
|
r["req_len"] = req.ContentLength
|
|
|
|
if req.URL != nil {
|
|
r["req_url"] = LoggableURL(req.URL.String())
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func respData(resp *http.Response) map[string]any {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
r := map[string]any{}
|
|
r["resp_status"] = resp.Status
|
|
r["resp_len"] = resp.ContentLength
|
|
|
|
return r
|
|
}
|
|
|
|
func appendIf(a []any, k string, v *string) []any {
|
|
if v == nil {
|
|
return a
|
|
}
|
|
|
|
return append(a, k, *v)
|
|
}
|
|
|
|
// ItemInfo gathers potentially useful information about a drive item,
|
|
// and aggregates that data into a map.
|
|
func ItemInfo(item models.DriveItemable) map[string]any {
|
|
m := map[string]any{}
|
|
|
|
creator := item.GetCreatedByUser()
|
|
if creator != nil {
|
|
m[fault.AddtlCreatedBy] = ptr.Val(creator.GetId())
|
|
}
|
|
|
|
lastmodder := item.GetLastModifiedByUser()
|
|
if lastmodder != nil {
|
|
m[fault.AddtlLastModBy] = ptr.Val(lastmodder.GetId())
|
|
}
|
|
|
|
parent := item.GetParentReference()
|
|
if parent != nil {
|
|
m[fault.AddtlContainerID] = ptr.Val(parent.GetId())
|
|
m[fault.AddtlContainerName] = ptr.Val(parent.GetName())
|
|
containerPath := ""
|
|
|
|
// Remove the "/drives/b!vF-sdsdsds-sdsdsa-sdsd/root:" prefix
|
|
splitPath := strings.SplitN(ptr.Val(parent.GetPath()), ":", 2)
|
|
if len(splitPath) > 1 {
|
|
containerPath = splitPath[1]
|
|
}
|
|
|
|
m[fault.AddtlContainerPath] = containerPath
|
|
}
|
|
|
|
malware := item.GetMalware()
|
|
if malware != nil {
|
|
m[fault.AddtlMalwareDesc] = ptr.Val(malware.GetDescription())
|
|
}
|
|
|
|
return m
|
|
}
|