Abhishek Pandey 757007e027
Skip graph call if the download url has expired (#4419)
<!-- 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
2023-10-09 13:42:54 +00:00

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
}