diff --git a/src/components/Search/SearchBar/client/__tests__/index.spec.ts b/src/components/Search/SearchBar/client/__tests__/index.spec.ts index bda3edd2..ffa042cb 100644 --- a/src/components/Search/SearchBar/client/__tests__/index.spec.ts +++ b/src/components/Search/SearchBar/client/__tests__/index.spec.ts @@ -10,10 +10,14 @@ import { __resetHeaderSearchForTests } from '@components/scripts/store/search' type SearchBarModule = WebComponentModule -type ActionResult = { data?: TData; error?: { message?: string } } +type ActionResult = { + data?: TData + error?: { code?: string; message?: string; status?: number } +} const searchQueryMock = vi.fn<(_input: { q: string; limit?: number }) => Promise>>() +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) vi.mock('astro:actions', () => ({ actions: { @@ -23,6 +27,10 @@ vi.mock('astro:actions', () => ({ }, })) +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + const flushMicrotasks = async () => { await Promise.resolve() await Promise.resolve() @@ -53,6 +61,7 @@ describe('SearchBar web component', () => { beforeEach(async () => { container = await AstroContainer.create() searchQueryMock.mockReset() + handleScriptErrorMock.mockReset() __resetHeaderSearchForTests() @@ -218,6 +227,56 @@ describe('SearchBar web component', () => { vi.useRealTimers() }) + it('silently ignores forbidden action results', async () => { + vi.useFakeTimers() + + await runComponentRender(async ({ element, window }) => { + searchQueryMock.mockResolvedValue({ + error: { + code: 'FORBIDDEN', + message: 'HTTP Client Error with status code: 403', + status: 403, + }, + }) + + const input = element.querySelector('[data-search-input]') as HTMLInputElement + const resultsContainer = element.querySelector('[data-search-results]') as HTMLElement + + input.value = 'blocked' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + + await vi.advanceTimersByTimeAsync(260) + await flushMicrotasks() + + expect(handleScriptErrorMock).not.toHaveBeenCalled() + expect(resultsContainer.classList.contains('hidden')).toBe(true) + }) + + vi.useRealTimers() + }) + + it('silently ignores forbidden thrown action errors', async () => { + vi.useFakeTimers() + + await runComponentRender(async ({ element, window }) => { + searchQueryMock.mockRejectedValue(new Error('HTTP Client Error with status code: 403')) + + const input = element.querySelector('[data-search-input]') as HTMLInputElement + const resultsContainer = element.querySelector('[data-search-results]') as HTMLElement + + input.value = 'blocked' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + + await vi.advanceTimersByTimeAsync(260) + await flushMicrotasks() + + expect(handleScriptErrorMock).not.toHaveBeenCalled() + expect(resultsContainer.classList.contains('hidden')).toBe(true) + }) + + vi.useRealTimers() + }) + it('toggles open and closes on Escape in header variant', async () => { await runHeaderComponentRender(async ({ element, window }) => { const toggleBtn = element.querySelector('[data-search-toggle]') as HTMLButtonElement diff --git a/src/components/Search/SearchBar/client/index.ts b/src/components/Search/SearchBar/client/index.ts index de6ea9f9..8731198d 100644 --- a/src/components/Search/SearchBar/client/index.ts +++ b/src/components/Search/SearchBar/client/index.ts @@ -3,6 +3,10 @@ import { render } from 'lit/html.js' import { actions } from 'astro:actions' import { defineCustomElement } from '@components/scripts/utils' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { + isForbiddenClientActionError, + normalizeClientActionError, +} from '@components/scripts/errors/actionClient' import { handleScriptError } from '@components/scripts/errors/handler' import { addScriptBreadcrumb } from '@components/scripts/errors' import { @@ -670,38 +674,57 @@ export class SearchBarElement extends LitElement { addScriptBreadcrumb(context) const requestId = ++this.latestRequestId - const { data, error } = await actions.search.query({ - q: query, - limit: HEADER_SEARCH_RESULT_LIMIT, - }) + try { + const { data, error } = await actions.search.query({ + q: query, + limit: HEADER_SEARCH_RESULT_LIMIT, + }) + const actionError = normalizeClientActionError(error) - // @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do. - if (error) { - handleScriptError(error, context) - this.clearResults() - this.hideResults() - return - } + // @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do. + if (error) { + if (isForbiddenClientActionError(actionError)) { + this.clearResults() + this.hideResults() + return + } - if (!data) { - this.clearResults() - this.hideResults() - return - } + handleScriptError(error, context) + this.clearResults() + this.hideResults() + return + } - if (requestId !== this.latestRequestId) { - return - } + if (!data) { + this.clearResults() + this.hideResults() + return + } - const hits = (data.hits ?? []) as SearchHit[] - if (hits.length === 0) { + if (requestId !== this.latestRequestId) { + return + } + + const hits = (data.hits ?? []) as SearchHit[] + if (hits.length === 0) { + this.clearResults() + this.hideResults() + return + } + + this.renderResults(query, hits) + this.showResults() + } catch (error) { + if (isForbiddenClientActionError(normalizeClientActionError(error))) { + this.clearResults() + this.hideResults() + return + } + + handleScriptError(error, context) this.clearResults() this.hideResults() - return } - - this.renderResults(query, hits) - this.showResults() } } diff --git a/src/components/Search/SearchResults/client/__tests__/index.spec.ts b/src/components/Search/SearchResults/client/__tests__/index.spec.ts index 1b5f756c..245e9207 100644 --- a/src/components/Search/SearchResults/client/__tests__/index.spec.ts +++ b/src/components/Search/SearchResults/client/__tests__/index.spec.ts @@ -7,7 +7,10 @@ import { executeRender } from '@test/unit/helpers/litRuntime' type SearchResultsModule = WebComponentModule -type ActionResult = { data?: TData; error?: { message?: string } } +type ActionResult = { + data?: TData + error?: { code?: string; message?: string; status?: number } +} const searchQueryMock = vi.fn< @@ -16,6 +19,7 @@ const searchQueryMock = limit?: number }) => Promise> >() +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) vi.mock('astro:actions', () => ({ actions: { @@ -25,6 +29,10 @@ vi.mock('astro:actions', () => ({ }, })) +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + const flushMicrotasks = async () => { await Promise.resolve() await Promise.resolve() @@ -36,6 +44,7 @@ describe('SearchResults web component', () => { beforeEach(async () => { container = await AstroContainer.create() searchQueryMock.mockReset() + handleScriptErrorMock.mockReset() }) const runComponentRender = async ( @@ -197,6 +206,40 @@ describe('SearchResults web component', () => { }) }) + it('silently ignores forbidden action results', async () => { + searchQueryMock.mockResolvedValue({ + error: { + code: 'FORBIDDEN', + message: 'HTTP Client Error with status code: 403', + status: 403, + }, + }) + + await runComponentRender({ query: 'blocked' }, async ({ element }) => { + await flushMicrotasks() + + expect(handleScriptErrorMock).not.toHaveBeenCalled() + expect(element.querySelector('[data-search-results] li')).toBeNull() + + const error = element.querySelector('[data-search-error]') + expect(error?.classList.contains('hidden')).toBe(true) + }) + }) + + it('silently ignores forbidden thrown action errors', async () => { + searchQueryMock.mockRejectedValue(new Error('HTTP Client Error with status code: 403')) + + await runComponentRender({ query: 'blocked' }, async ({ element }) => { + await flushMicrotasks() + + expect(handleScriptErrorMock).not.toHaveBeenCalled() + expect(element.querySelector('[data-search-results] li')).toBeNull() + + const error = element.querySelector('[data-search-error]') + expect(error?.classList.contains('hidden')).toBe(true) + }) + }) + it('supports a custom limit without rendering the built-in empty state', async () => { searchQueryMock.mockResolvedValue({ data: { diff --git a/src/components/Search/SearchResults/client/index.ts b/src/components/Search/SearchResults/client/index.ts index 474dd61e..4e14ff5d 100644 --- a/src/components/Search/SearchResults/client/index.ts +++ b/src/components/Search/SearchResults/client/index.ts @@ -2,6 +2,10 @@ import { LitElement } from 'lit' import { actions } from 'astro:actions' import { defineCustomElement } from '@components/scripts/utils' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { + isForbiddenClientActionError, + normalizeClientActionError, +} from '@components/scripts/errors/actionClient' import { handleScriptError } from '@components/scripts/errors/handler' import { addScriptBreadcrumb } from '@components/scripts/errors' import { addButtonEventListeners } from '@components/scripts/elementListeners' @@ -578,39 +582,62 @@ export class SearchResultsElement extends LitElement { addScriptBreadcrumb(context) const requestId = ++this.latestRequestId - const { data, error } = await actions.search.query({ q: query, limit: this.limit }) + try { + const { data, error } = await actions.search.query({ q: query, limit: this.limit }) + const actionError = normalizeClientActionError(error) - // @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do. - if (error) { - const message = error instanceof Error ? error.message : 'Search failed.' - handleScriptError(error, context) - this.renderResults([]) - this.clearMeta() - this.showError(message) - return - } + // @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do. + if (error) { + const message = actionError?.message ?? (error instanceof Error ? error.message : 'Search failed.') - if (!data) { - this.renderResults([]) - this.clearMeta() - return - } + if (isForbiddenClientActionError(actionError)) { + this.renderResults([]) + this.clearMeta() + return + } - if (requestId !== this.latestRequestId) { - return - } + handleScriptError(error, context) + this.renderResults([]) + this.clearMeta() + this.showError(message) + return + } + + if (!data) { + this.renderResults([]) + this.clearMeta() + return + } - const hits = (data.hits ?? []) as SearchHit[] - this.renderResults(hits) - if (hits.length === 0 && this.shouldSuppressMetaFeedback()) { + if (requestId !== this.latestRequestId) { + return + } + + const hits = (data.hits ?? []) as SearchHit[] + this.renderResults(hits) + if (hits.length === 0 && this.shouldSuppressMetaFeedback()) { + this.clearMeta() + return + } + + this.setMeta( + this.getResultsMetaMessage(query, hits), + hits.length > 0 || !this.shouldSuppressMetaFeedback() + ) + } catch (error) { + const actionError = normalizeClientActionError(error) + + if (isForbiddenClientActionError(actionError)) { + this.renderResults([]) + this.clearMeta() + return + } + + handleScriptError(error, context) + this.renderResults([]) this.clearMeta() - return + this.showError(actionError?.message ?? (error instanceof Error ? error.message : 'Search failed.')) } - - this.setMeta( - this.getResultsMetaMessage(query, hits), - hits.length > 0 || !this.shouldSuppressMetaFeedback() - ) } } diff --git a/src/components/WebMentions/client/__tests__/index.spec.ts b/src/components/WebMentions/client/__tests__/index.spec.ts index 578cf85f..1d4371d7 100644 --- a/src/components/WebMentions/client/__tests__/index.spec.ts +++ b/src/components/WebMentions/client/__tests__/index.spec.ts @@ -170,6 +170,28 @@ describe('WebMentions web component', () => { ) }) + test('treats forbidden action results as an empty state without reporting them', async () => { + webmentionsListMock.mockResolvedValue({ + data: undefined, + error: { + code: 'FORBIDDEN', + message: 'HTTP Client Error with status code: 403', + status: 403, + }, + }) + + await runComponentRender( + async ({ element }) => { + await flushMicrotasks() + await element.updateComplete + + expect(element.querySelector('#webmentions')).toBeNull() + expect(handleScriptErrorMock).not.toHaveBeenCalled() + }, + { url: 'https://example.com/forbidden-post' } + ) + }) + test('fails silently and reports thrown load errors through the client error handler', async () => { const thrownError = new Error('Network blew up') webmentionsListMock.mockRejectedValue(thrownError) @@ -188,4 +210,19 @@ describe('WebMentions web component', () => { { url: 'https://example.com/thrown-error-post' } ) }) + + test('treats forbidden thrown load errors as an empty state without reporting them', async () => { + webmentionsListMock.mockRejectedValue(new Error('HTTP Client Error with status code: 403')) + + await runComponentRender( + async ({ element }) => { + await flushMicrotasks() + await element.updateComplete + + expect(element.querySelector('#webmentions')).toBeNull() + expect(handleScriptErrorMock).not.toHaveBeenCalled() + }, + { url: 'https://example.com/forbidden-thrown-post' } + ) + }) }) diff --git a/src/components/WebMentions/client/index.ts b/src/components/WebMentions/client/index.ts index 32eb3993..e459161b 100644 --- a/src/components/WebMentions/client/index.ts +++ b/src/components/WebMentions/client/index.ts @@ -3,6 +3,10 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js' import { actions } from 'astro:actions' import { defineCustomElement } from '@components/scripts/utils' import { handleScriptError } from '@components/scripts/errors/handler' +import { + isForbiddenClientActionError, + normalizeClientActionError, +} from '@components/scripts/errors/actionClient' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import type { WebmentionDisplayItem, WebmentionsListResult } from '@actions/webmentions/@types' import { queryWebMentionsIconMarkup } from './selectors' @@ -11,6 +15,11 @@ type LoadState = 'idle' | 'loading' | 'ready' | 'error' const webmentionsCache = new Map() const scriptName = 'WebMentionsElement' +const emptyWebmentionsResult: WebmentionsListResult = { + likesCount: 0, + mentions: [], + repostsCount: 0, +} const formatDate = (dateString: string): string => { const date = new Date(dateString) @@ -108,7 +117,15 @@ export class WebMentionsElement extends LitElement { try { const { data, error } = await actions.webmentions.list({ url: normalizedUrl }) + const actionError = normalizeClientActionError(error) + if (error || !data) { + if (isForbiddenClientActionError(actionError)) { + webmentionsCache.set(normalizedUrl, emptyWebmentionsResult) + this.applyData(emptyWebmentionsResult) + return + } + handleScriptError(error ?? new Error('Failed to load WebMentions data.'), { scriptName, operation: 'load', @@ -125,6 +142,14 @@ export class WebMentionsElement extends LitElement { webmentionsCache.set(normalizedUrl, data) this.applyData(data) } catch (error) { + const actionError = normalizeClientActionError(error) + + if (isForbiddenClientActionError(actionError)) { + webmentionsCache.set(normalizedUrl, emptyWebmentionsResult) + this.applyData(emptyWebmentionsResult) + return + } + handleScriptError(error, { scriptName, operation: 'load' }) if (this.lastLoadedUrl === normalizedUrl) { diff --git a/src/components/scripts/errors/actionClient.ts b/src/components/scripts/errors/actionClient.ts new file mode 100644 index 00000000..c2f65c54 --- /dev/null +++ b/src/components/scripts/errors/actionClient.ts @@ -0,0 +1,108 @@ +export interface ClientActionError { + code?: string + message?: string + status?: number +} + +const getErrorRecord = (value: unknown): Record | undefined => { + if (typeof value !== 'object' || value === null) { + return undefined + } + + return value as Record +} + +const parseStatusCode = (value: unknown): number | undefined => { + const statusCode = typeof value === 'number' ? value : Number(value) + + if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) { + return undefined + } + + return statusCode +} + +const parseStatusCodeFromMessage = (message?: string): number | undefined => { + const match = message?.match(/status code:\s*(\d{3})/i) + + if (!match?.[1]) { + return undefined + } + + return parseStatusCode(match[1]) +} + +const createClientActionError = (params: { + code?: string | undefined + message?: string | undefined + status?: number | undefined +}): ClientActionError => { + const actionError: ClientActionError = {} + + if (params.code !== undefined) actionError.code = params.code + if (params.message !== undefined) actionError.message = params.message + if (params.status !== undefined) actionError.status = params.status + + return actionError +} + +export const normalizeClientActionError = (value: unknown): ClientActionError | undefined => { + if (!value) { + return undefined + } + + if (typeof value === 'string') { + return createClientActionError({ + message: value, + status: parseStatusCodeFromMessage(value), + }) + } + + if (value instanceof Error) { + const errorRecord = getErrorRecord(value.cause) + + return createClientActionError({ + code: typeof errorRecord?.['code'] === 'string' ? errorRecord['code'] : undefined, + message: value.message, + status: + parseStatusCode(errorRecord?.['status']) ?? + parseStatusCode(errorRecord?.['statusCode']) ?? + parseStatusCodeFromMessage(value.message), + }) + } + + const errorRecord = getErrorRecord(value) + if (!errorRecord) { + return createClientActionError({ + message: String(value), + }) + } + + const causeRecord = getErrorRecord(errorRecord['cause']) + const message = + typeof errorRecord['message'] === 'string' + ? errorRecord['message'] + : typeof causeRecord?.['message'] === 'string' + ? causeRecord['message'] + : undefined + + return createClientActionError({ + code: + typeof errorRecord['code'] === 'string' + ? errorRecord['code'] + : typeof causeRecord?.['code'] === 'string' + ? causeRecord['code'] + : undefined, + message, + status: + parseStatusCode(errorRecord['status']) ?? + parseStatusCode(errorRecord['statusCode']) ?? + parseStatusCode(causeRecord?.['status']) ?? + parseStatusCode(causeRecord?.['statusCode']) ?? + parseStatusCodeFromMessage(message), + }) +} + +export const isForbiddenClientActionError = (error?: ClientActionError): boolean => { + return error?.code === 'FORBIDDEN' || error?.status === 403 +} \ No newline at end of file diff --git a/src/components/scripts/sentry/__tests__/helpers.spec.ts b/src/components/scripts/sentry/__tests__/helpers.spec.ts index 1d7470a8..5c4bdc41 100644 --- a/src/components/scripts/sentry/__tests__/helpers.spec.ts +++ b/src/components/scripts/sentry/__tests__/helpers.spec.ts @@ -122,6 +122,91 @@ const createNewsletterSubscribeHttpErrorEvent = (): Parameters[0] +const createNewsletterConfirmHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/newsletter.confirm' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 403', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + +const createSearchQueryHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/search.query' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 403', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + +const createMyDataVerifyHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.verifyDsar' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 403', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + +const createMyDataRequestHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.requestData' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 403', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + +const createWebmentionsHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/webmentions.list' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 403', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + const createConsentLogRetryErrorEvent = (): Parameters[0] => ({ type: 'error', @@ -258,6 +343,61 @@ describe('sentry helpers', () => { expect(result).toBeNull() }) + it('drops handled newsletter confirm http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createNewsletterConfirmHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + + it('drops handled search http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createSearchQueryHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + + it('drops handled my-data verify http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createMyDataVerifyHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + + it('drops handled my-data request http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createMyDataRequestHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + + it('drops handled webmentions http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createWebmentionsHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + it('drops handled consent log retry errors', () => { isProdMock.mockReturnValue(true) getConsentSnapshotMock.mockReturnValue({ analytics: true }) diff --git a/src/components/scripts/sentry/helpers.ts b/src/components/scripts/sentry/helpers.ts index 6abd8efb..d91edb09 100644 --- a/src/components/scripts/sentry/helpers.ts +++ b/src/components/scripts/sentry/helpers.ts @@ -76,6 +76,98 @@ const isHandledNewsletterSubscribeHttpError = ( ) } +const isNewsletterConfirmActionRequest = (requestUrl: string): boolean => { + return ( + requestUrl.includes('/_actions/newsletter.confirm') || + requestUrl.includes('/_actions/newsletter/confirm') + ) +} + +const isHandledNewsletterConfirmHttpError = (event: Parameters[0]): boolean => { + const requestUrl = event.request?.url + const exception = event.exception?.values?.[0] + const mechanismType = exception?.mechanism?.type + const errorMessage = exception?.value ?? event.message ?? '' + + return ( + typeof requestUrl === 'string' && + isNewsletterConfirmActionRequest(requestUrl) && + mechanismType === 'auto.http.client.fetch' && + typeof errorMessage === 'string' && + errorMessage.includes('HTTP Client Error with status code:') + ) +} + +const isSearchActionRequest = (requestUrl: string): boolean => { + return ( + requestUrl.includes('/_actions/search.query') || + requestUrl.includes('/_actions/search/query') + ) +} + +const isHandledSearchHttpError = (event: Parameters[0]): boolean => { + const requestUrl = event.request?.url + const exception = event.exception?.values?.[0] + const mechanismType = exception?.mechanism?.type + const errorMessage = exception?.value ?? event.message ?? '' + + return ( + typeof requestUrl === 'string' && + isSearchActionRequest(requestUrl) && + mechanismType === 'auto.http.client.fetch' && + typeof errorMessage === 'string' && + errorMessage.includes('HTTP Client Error with status code:') + ) +} + +const isMyDataActionRequest = (requestUrl: string): boolean => { + return ( + requestUrl.includes('/_actions/gdpr.verifyDsar') || + requestUrl.includes('/_actions/gdpr/verifyDsar') || + requestUrl.includes('/_actions/gdpr.requestData') || + requestUrl.includes('/_actions/gdpr/requestData') + ) +} + +const isHandledMyDataHttpError = (event: Parameters[0]): boolean => { + const requestUrl = event.request?.url + const exception = event.exception?.values?.[0] + const mechanismType = exception?.mechanism?.type + const errorMessage = exception?.value ?? event.message ?? '' + + return ( + typeof requestUrl === 'string' && + isMyDataActionRequest(requestUrl) && + mechanismType === 'auto.http.client.fetch' && + typeof errorMessage === 'string' && + errorMessage.includes('HTTP Client Error with status code:') + ) +} + +const isWebmentionsActionRequest = (requestUrl: string): boolean => { + return ( + requestUrl.includes('/_actions/webmentions.list') || + requestUrl.includes('/_actions/webmentions/list') + ) +} + +const isHandledWebmentionsHttpError = (event: Parameters[0]): boolean => { + const requestUrl = event.request?.url + const exception = event.exception?.values?.[0] + const mechanismType = exception?.mechanism?.type + const errorMessage = exception?.value ?? event.message ?? '' + const statusCodeMatch = + typeof errorMessage === 'string' ? errorMessage.match(/status code:\s*(\d{3})/i) : null + const statusCode = statusCodeMatch?.[1] ? Number(statusCodeMatch[1]) : undefined + + return ( + typeof requestUrl === 'string' && + isWebmentionsActionRequest(requestUrl) && + mechanismType === 'auto.http.client.fetch' && + statusCode === 403 + ) +} + const isHandledConsentLogRetryError = (event: Parameters[0]): boolean => { const errorMessage = event.exception?.values?.[0]?.value ?? event.message ?? '' const tags = event.tags ?? {} @@ -151,6 +243,31 @@ export const beforeSendHandler: BeforeSendHandler = (event, _hint) => { return null } + // Newsletter confirmation handles action failures in the UI, so the + // browser-side auto-fetch event is duplicate noise. + if (isHandledNewsletterConfirmHttpError(event)) { + return null + } + + // Search handles action failures in the UI. Drop the browser-side auto-fetch + // event and rely on the client-side fallback behavior instead. + if (isHandledSearchHttpError(event)) { + return null + } + + // My Data verification and request flows render their own failure states, so + // drop the duplicate browser-side auto-fetch event. + if (isHandledMyDataHttpError(event)) { + return null + } + + // Webmentions are non-critical content enhancement. If the action is blocked + // with a 403, the component degrades to an empty state and the auto-fetch + // browser event becomes noise. + if (isHandledWebmentionsHttpError(event)) { + return null + } + // Consent logging retries are best-effort and user-invisible. If a handled // client exception still gets emitted from this path, drop it as noise. if (isHandledConsentLogRetryError(event)) {