Skip to content
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

feat: retry invalid/expired refs #356

Merged
merged 7 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
62 changes: 55 additions & 7 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,9 +529,9 @@ export class Client<
async get<TDocument extends TDocuments>(
params?: Partial<BuildQueryURLArgs> & FetchParams,
): Promise<Query<TDocument>> {
const url = await this.buildQueryURL(params)
const { data } = await this._get<TDocument>(params)

return await this.fetch<Query<TDocument>>(url, params)
return data
}

/**
Expand All @@ -546,8 +546,9 @@ export class Client<
*
* @typeParam TDocument - Type of the Prismic document returned.
*
* @param params - Parameters to filter, sort, and paginate results. @returns
* The first result of the query, if any.
* @param params - Parameters to filter, sort, and paginate results.
*
* @returns The first result of the query, if any.
*/
async getFirst<TDocument extends TDocuments>(
params?: Partial<BuildQueryURLArgs> & FetchParams,
Expand All @@ -556,10 +557,9 @@ export class Client<
if (!(params && params.page) && !params?.pageSize) {
actualParams.pageSize = this.defaultParams?.pageSize ?? 1
}
const url = await this.buildQueryURL(actualParams)
const result = await this.fetch<Query<TDocument>>(url, params)
const { data, url } = await this._get<TDocument>(actualParams)

const firstResult = result.results[0]
const firstResult = data.results[0]

if (firstResult) {
return firstResult
Expand Down Expand Up @@ -1678,6 +1678,54 @@ export class Client<
return findMasterRef(cachedRepository.refs).ref
}

/**
* The private implementation of `this.get`. It returns the API response and
* the URL used to make the request. The URL is sometimes used in the public
* method to include in thrown errors.
*
* This method retries requests that throw `RefNotFoundError` or
* `RefExpiredError`. It contains special logic to retry with the latest
* master ref, provided in the API's error message.
*
* @typeParam TDocument - Type of Prismic documents returned.
*
* @param params - Parameters to filter, sort, and paginate results.
*
* @returns An object containing the paginated response containing the result
* of the query and the URL used to make the API request.
*/
private async _get<TDocument extends TDocuments>(
params?: Partial<BuildQueryURLArgs> & FetchParams,
): Promise<{ data: Query<TDocument>; url: string }> {
const url = await this.buildQueryURL(params)

try {
const data = await this.fetch<Query<TDocument>>(url, params)

return { data, url }
} catch (error) {
if (
!(error instanceof RefNotFoundError || error instanceof RefExpiredError)
) {
throw error
}

const masterRef = error.message.match(/Master ref is: (?<ref>.*)$/)
?.groups?.ref
if (!masterRef) {
throw error
}

const badRef = new URL(url).searchParams.get("ref")
const issue = error instanceof RefNotFoundError ? "invalid" : "expired"
console.warn(
`The ref (${badRef}) was ${issue}. Now retrying with the latest master ref (${masterRef}). If you were previewing content, the response will not include draft content.`,
)

return await this._get({ ...params, ref: masterRef })
}
}

/**
* Performs a network request using the configured `fetch` function. It
* assumes all successful responses will have a JSON content type. It also
Expand Down
98 changes: 98 additions & 0 deletions test/__testutils__/testInvalidRefRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect, it, vi } from "vitest"

import { rest } from "msw"

import { createTestClient } from "./createClient"
import { mockPrismicRestAPIV2 } from "./mockPrismicRestAPIV2"

import type * as prismic from "../../src"

type TestInvalidRefRetryArgs = {
run: (
client: prismic.Client,
params?: Parameters<prismic.Client["get"]>[0],
) => Promise<unknown>
}

export const testInvalidRefRetry = (args: TestInvalidRefRetryArgs): void => {
it.concurrent(
"retries with the master ref when an invalid ref is used",
async (ctx) => {
const client = createTestClient({ ctx })
const badRef = ctx.mock.api.ref().ref
const masterRef = ctx.mock.api.ref().ref
const queryResponse = ctx.mock.api.query({
documents: [ctx.mock.value.document()],
})

const triedRefs: (string | null)[] = []

mockPrismicRestAPIV2({ ctx, queryResponse })
const endpoint = new URL(
"documents/search",
`${client.documentAPIEndpoint}/`,
).toString()
ctx.server.use(
rest.get(endpoint, (req) => {
triedRefs.push(req.url.searchParams.get("ref"))
}),
rest.get(endpoint, (_req, res, ctx) =>
res.once(
ctx.json({
type: "api_notfound_error",
message: `Master ref is: ${masterRef}`,
}),
ctx.status(404),
),
),
)

const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => void 0)
await args.run(client, { ref: badRef })
consoleWarnSpy.mockRestore()

expect(triedRefs).toStrictEqual([badRef, masterRef])
},
)

it.concurrent(
"retries with the master ref when an expired ref is used",
async (ctx) => {
const client = createTestClient({ ctx })
const badRef = ctx.mock.api.ref().ref
const masterRef = ctx.mock.api.ref().ref
const queryResponse = ctx.mock.api.query({
documents: [ctx.mock.value.document()],
})

const triedRefs: (string | null)[] = []

mockPrismicRestAPIV2({ ctx, queryResponse })
const endpoint = new URL(
"documents/search",
`${client.documentAPIEndpoint}/`,
).toString()
ctx.server.use(
rest.get(endpoint, (req) => {
triedRefs.push(req.url.searchParams.get("ref"))
}),
rest.get(endpoint, (_req, res, ctx) =>
res.once(
ctx.json({ message: `Master ref is: ${masterRef}` }),
ctx.status(410),
),
),
)

const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => void 0)
await args.run(client, { ref: badRef })
consoleWarnSpy.mockRestore()

expect(triedRefs).toStrictEqual([badRef, masterRef])
},
)
}
5 changes: 5 additions & 0 deletions test/client-get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2"
import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetMethod } from "./__testutils__/testAnyGetMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetMethod("resolves a query", {
run: (client) => client.get(),
Expand Down Expand Up @@ -92,6 +93,10 @@ it("uses cached repository metadata within the client's repository cache TTL", a
)
})

testInvalidRefRetry({
run: (client, params) => client.get(params),
})

testFetchOptions("supports fetch options", {
run: (client, params) => client.get(params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getAllByEveryTag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetAllMethod("returns all documents by every tag from paginated response", {
run: (client) => client.getAllByEveryTag(["foo", "bar"]),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllByEveryTag(["foo", "bar"], params),
})

testInvalidRefRetry({
run: (client, params) => client.getAllByEveryTag(["foo", "bar"], params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getAllByEveryTag(["foo", "bar"], params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getAllByIDs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetAllMethod("returns all documents by IDs from paginated response", {
run: (client) => client.getAllByIDs(["id1", "id2"]),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllByIDs(["id1", "id2"], params),
})

testInvalidRefRetry({
run: (client, params) => client.getAllByIDs(["id1", "id2"], params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getAllByIDs(["id1", "id2"], params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getAllBySomeTags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetAllMethod("returns all documents by some tags from paginated response", {
run: (client) => client.getAllBySomeTags(["foo", "bar"]),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllBySomeTags(["foo", "bar"], params),
})

testInvalidRefRetry({
run: (client, params) => client.getAllBySomeTags(["foo", "bar"], params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getAllBySomeTags(["foo", "bar"], params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getAllByTag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetAllMethod("returns all documents by tag from paginated response", {
run: (client) => client.getAllByTag("tag"),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllByTag("tag", params),
})

testInvalidRefRetry({
run: (client, params) => client.getAllByTag("tag", params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getAllByTag("tag", params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getAllByType.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetAllMethod("returns all documents by type from paginated response", {
run: (client) => client.getAllByType("type"),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllByType("type", params),
})

testInvalidRefRetry({
run: (client, params) => client.getAllByType("type", params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getAllByType("type", params),
})
Expand Down
6 changes: 6 additions & 0 deletions test/client-getAllByUIDs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetAllMethod("returns all documents by UIDs from paginated response", {
run: (client) => client.getAllByUIDs("type", ["uid1", "uid2"]),
Expand Down Expand Up @@ -36,6 +37,11 @@ testFetchOptions("supports fetch options", {
client.getAllByUIDs("type", ["uid1", "uid2"], params),
})

testInvalidRefRetry({
run: (client, params) =>
client.getAllByUIDs("type", ["uid1", "uid2"], params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) =>
client.getAllByUIDs("type", ["uid1", "uid2"], params),
Expand Down
5 changes: 5 additions & 0 deletions test/client-getByEveryTag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetMethod("queries for documents by tag", {
run: (client) => client.getByEveryTag(["foo", "bar"]),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getByEveryTag(["foo", "bar"], params),
})

testInvalidRefRetry({
run: (client, params) => client.getByEveryTag(["foo", "bar"], params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getByEveryTag(["foo", "bar"], params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getByID.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetFirstMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetFirstMethod("queries for document by ID", {
run: (client) => client.getByID("id"),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getByID("id", params),
})

testInvalidRefRetry({
run: (client, params) => client.getByID("id", params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getByID("id", params),
})
Expand Down
5 changes: 5 additions & 0 deletions test/client-getByIDs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { testAbortableMethod } from "./__testutils__/testAbortableMethod"
import { testGetMethod } from "./__testutils__/testAnyGetMethod"
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod"
import { testFetchOptions } from "./__testutils__/testFetchOptions"
import { testInvalidRefRetry } from "./__testutils__/testInvalidRefRetry"

testGetMethod("queries for documents by IDs", {
run: (client) => client.getByIDs(["id1", "id2"]),
Expand Down Expand Up @@ -29,6 +30,10 @@ testFetchOptions("supports fetch options", {
run: (client, params) => client.getByIDs(["id1", "id2"], params),
})

testInvalidRefRetry({
run: (client, params) => client.getByIDs(["id1", "id2"], params),
})

testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.getByIDs(["id1", "id2"], params),
})
Expand Down
Loading
Loading