Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion packages/drivers/odsp-driver/src/fetchSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,6 +59,7 @@ import {
fetchAndParseAsJSONHelper,
fetchHelper,
getWithRetryForTokenRefresh,
getOdspResolvedUrl,
getWithRetryForTokenRefreshRepeat,
isSnapshotFetchForLoadingGroup,
measure,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -791,6 +823,15 @@ export const downloadSnapshot = mockify(
},
);

function isLocationRedirectionError(error: unknown): error is ILocationRedirectionError {
return (
typeof error === "object" &&
error !== null &&
(error as Partial<IOdspError>).errorType === DriverErrorTypes.locationRedirection &&
(error as Partial<ILocationRedirectionError>).redirectUrl !== undefined
);
}

function isRedeemSharingLinkError(
odspResolvedUrl: IOdspResolvedUrl,
error: Partial<IOdspError>,
Expand Down
138 changes: 137 additions & 1 deletion packages/drivers/odsp-driver/src/test/fetchSnapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MockResponse> =>
createResponse(
{ "x-fluid-epoch": "epoch1" },
{
error: {
"message": "locationMoved",
"@error.redirectLocation": newSiteUrl,
},
},
404,
),
// Second fetch is the redeem /shares API call
async (): Promise<MockResponse> => okResponse({}, {}),
],
);
assert.fail("Should have thrown a locationRedirection error");
} catch (error: unknown) {
assert.strictEqual(
(error as Partial<IFluidErrorBase>).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<MockResponse> =>
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<IFluidErrorBase>).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<MockResponse> =>
createResponse(
{ "x-fluid-epoch": "epoch1" },
{
error: {
"message": "locationMoved",
"@error.redirectLocation": newSiteUrl,
},
},
404,
),
// Second fetch is the redeem /shares API call - returns failure
async (): Promise<MockResponse> =>
createResponse({}, { error: "redeemFailed" }, 500),
],
);
assert.fail("Should have thrown a locationRedirection error");
} catch (error: unknown) {
assert.strictEqual(
(error as Partial<IFluidErrorBase>).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 = {
Expand Down
Loading