diff --git a/packages/drivers/odsp-driver/src/fetchSnapshot.ts b/packages/drivers/odsp-driver/src/fetchSnapshot.ts index 903f1854a7cb..91b2d0c86c6b 100644 --- a/packages/drivers/odsp-driver/src/fetchSnapshot.ts +++ b/packages/drivers/odsp-driver/src/fetchSnapshot.ts @@ -6,7 +6,12 @@ import { fromUtf8ToBase64 } from "@fluid-internal/client-utils"; import { assert } from "@fluidframework/core-utils/internal"; import { getW3CData } from "@fluidframework/driver-base/internal"; -import type { ISnapshot, ISnapshotTree } from "@fluidframework/driver-definitions/internal"; +import { + DriverErrorTypes, + type ILocationRedirectionError, + type ISnapshot, + type ISnapshotTree, +} from "@fluidframework/driver-definitions/internal"; import { type DriverErrorTelemetryProps, NonRetryableError, @@ -54,6 +59,7 @@ import { fetchAndParseAsJSONHelper, fetchHelper, getWithRetryForTokenRefresh, + getOdspResolvedUrl, getWithRetryForTokenRefreshRepeat, isSnapshotFetchForLoadingGroup, measure, @@ -183,6 +189,32 @@ export async function fetchSnapshotWithRedeem( putInCache, loadingGroupIds, ); + } else if ( + isLocationRedirectionError(error) && + odspResolvedUrl.shareLinkInfo?.sharingLinkToRedeem !== undefined + ) { + try { + // The redirect itself is handled earlier, but we need to redeem the sharing link + // now against the redirected URL rather than waiting until the next API call retries + // with the redirect URL applied. After this point the sharing link is removed from + // the resolved URL, so we wouldn't be able to redeem during a later failure. + const redirectedResolvedUrl: IOdspResolvedUrl = { + ...getOdspResolvedUrl(error.redirectUrl), + shareLinkInfo: odspResolvedUrl.shareLinkInfo, + }; + + await redeemSharingLink(redirectedResolvedUrl, storageTokenFetcher, logger); + logger.sendTelemetryEvent( + { + eventName: "RedirectRedeemFallback", + errorType: error.errorType, + }, + error, + ); + } catch (redeemError) { + logger.sendErrorEvent({ eventName: "RedirectRedeemFallbackError" }, redeemError); + } + throw error; } else { throw error; } @@ -791,6 +823,15 @@ export const downloadSnapshot = mockify( }, ); +function isLocationRedirectionError(error: unknown): error is ILocationRedirectionError { + return ( + typeof error === "object" && + error !== null && + (error as Partial).errorType === DriverErrorTypes.locationRedirection && + (error as Partial).redirectUrl !== undefined + ); +} + function isRedeemSharingLinkError( odspResolvedUrl: IOdspResolvedUrl, error: Partial, diff --git a/packages/drivers/odsp-driver/src/test/fetchSnapshot.spec.ts b/packages/drivers/odsp-driver/src/test/fetchSnapshot.spec.ts index 6d7ad70a9a91..2e49212c6e9c 100644 --- a/packages/drivers/odsp-driver/src/test/fetchSnapshot.spec.ts +++ b/packages/drivers/odsp-driver/src/test/fetchSnapshot.spec.ts @@ -8,7 +8,11 @@ import { strict as assert } from "node:assert"; import { stringToBuffer } from "@fluid-internal/client-utils"; -import type { ISnapshot, ISnapshotTree } from "@fluidframework/driver-definitions/internal"; +import { + DriverErrorTypes, + type ISnapshot, + type ISnapshotTree, +} from "@fluidframework/driver-definitions/internal"; import { type IOdspResolvedUrl, OdspErrorTypes, @@ -628,6 +632,138 @@ describe("Tests1 for snapshot fetch", () => { ]), ); }); + + it("Redeem sharing link on location redirection error", async () => { + resolved.shareLinkInfo = { + sharingLinkToRedeem: "https://microsoft.sharepoint-df.com/sharelink", + }; + + const newSiteUrl = "https://microsoft.sharepoint.com/siteUrl"; + + try { + await mockFetchMultiple( + async () => service.getSnapshot({}), + [ + // First fetch (trees/latest) returns 404 with redirectLocation + async (): Promise => + createResponse( + { "x-fluid-epoch": "epoch1" }, + { + error: { + "message": "locationMoved", + "@error.redirectLocation": newSiteUrl, + }, + }, + 404, + ), + // Second fetch is the redeem /shares API call + async (): Promise => okResponse({}, {}), + ], + ); + assert.fail("Should have thrown a locationRedirection error"); + } catch (error: unknown) { + assert.strictEqual( + (error as Partial).errorType, + DriverErrorTypes.locationRedirection, + "Error should be a locationRedirection error", + ); + } + assert( + mockLogger.matchEvents([ + { eventName: "TreesLatest_cancel" }, + { + eventName: "RedeemShareLink_end", + details: + '{"shareLinkUrlLength":45,"queryParamsLength":0,"useHeaders":true,"isRedemptionNonDurable":false}', + }, + { eventName: "RedirectRedeemFallback" }, + ]), + "Should have redeemed sharing link before re-throwing the redirection error", + ); + }); + + it("Location redirection error without shareLink skips redeem", async () => { + // No shareLinkInfo set on resolved URL + resolved.shareLinkInfo = undefined; + + const newSiteUrl = "https://microsoft.sharepoint.com/siteUrl"; + + try { + await mockFetchMultiple( + async () => service.getSnapshot({}), + [ + async (): Promise => + createResponse( + { "x-fluid-epoch": "epoch1" }, + { + error: { + "message": "locationMoved", + "@error.redirectLocation": newSiteUrl, + }, + }, + 404, + ), + ], + ); + assert.fail("Should have thrown a locationRedirection error"); + } catch (error: unknown) { + assert.strictEqual( + (error as Partial).errorType, + DriverErrorTypes.locationRedirection, + "Error should be a locationRedirection error", + ); + } + assert( + !mockLogger.matchAnyEvent([{ eventName: "RedeemShareLink_end" }]), + "Should not have attempted redeem without a shareLink", + ); + assert( + !mockLogger.matchAnyEvent([{ eventName: "RedirectRedeemFallback" }]), + "Should not have logged redirect redeem fallback without a shareLink", + ); + }); + + it("Location redirection error still throws when redeem fails", async () => { + resolved.shareLinkInfo = { + sharingLinkToRedeem: "https://microsoft.sharepoint-df.com/sharelink", + }; + + const newSiteUrl = "https://microsoft.sharepoint.com/siteUrl"; + + try { + await mockFetchMultiple( + async () => service.getSnapshot({}), + [ + // First fetch (trees/latest) returns 404 with redirectLocation + async (): Promise => + createResponse( + { "x-fluid-epoch": "epoch1" }, + { + error: { + "message": "locationMoved", + "@error.redirectLocation": newSiteUrl, + }, + }, + 404, + ), + // Second fetch is the redeem /shares API call - returns failure + async (): Promise => + createResponse({}, { error: "redeemFailed" }, 500), + ], + ); + assert.fail("Should have thrown a locationRedirection error"); + } catch (error: unknown) { + assert.strictEqual( + (error as Partial).errorType, + DriverErrorTypes.locationRedirection, + "Original redirection error should be thrown even when redeem fails", + ); + } + assert( + mockLogger.matchAnyEvent([{ eventName: "RedirectRedeemFallbackError" }]), + "Should have logged redeem failure telemetry", + ); + }); }); const snapshotTreeWithGroupId: ISnapshotTree = {