diff --git a/src/components/Pages/Downloads/client/__tests__/index.spec.ts b/src/components/Pages/Downloads/client/__tests__/index.spec.ts
index f2b232b7f..266a73eb3 100644
--- a/src/components/Pages/Downloads/client/__tests__/index.spec.ts
+++ b/src/components/Pages/Downloads/client/__tests__/index.spec.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { DownloadFormElements } from './testUtils'
+import * as errorHandlerModule from '@components/scripts/errors/handler'
const downloadsSubmitMock = vi.fn()
const markEmailCollectedMock = vi.fn()
@@ -37,8 +38,6 @@ const defaultFormValues = {
firstName: 'Jane',
lastName: 'Doe',
workEmail: 'jane@example.com',
- jobTitle: 'Engineer',
- companyName: 'Acme Corp',
}
const fillDownloadForm = (
@@ -49,8 +48,6 @@ const fillDownloadForm = (
elements.firstName.value = values.firstName
elements.lastName.value = values.lastName
elements.workEmail.value = values.workEmail
- elements.jobTitle.value = values.jobTitle
- elements.companyName.value = values.companyName
return values
}
@@ -93,7 +90,11 @@ describe('download-form web component', () => {
submitForm(window, elements.form)
await flushPromises()
- expect(downloadsSubmitMock).toHaveBeenCalledWith(payload)
+ expect(downloadsSubmitMock).toHaveBeenCalledWith({
+ firstName: payload.firstName,
+ lastName: payload.lastName,
+ workEmail: payload.workEmail,
+ })
expect(markEmailCollectedMock).toHaveBeenCalledWith('jane@example.com', 'download_form')
})
})
@@ -142,12 +143,14 @@ describe('download-form web component', () => {
it('displays error state when API fails', async () => {
await renderDownloadForm(async ({ elements, window }) => {
downloadsSubmitMock.mockResolvedValue({ error: { message: 'Server error' } })
+ const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError')
fillDownloadForm(elements)
submitForm(window, elements.form)
await flushPromises()
expect(downloadsSubmitMock).toHaveBeenCalled()
+ expect(handleScriptErrorSpy).not.toHaveBeenCalled()
expect(elements.statusDiv.classList.contains('hidden')).toBe(false)
expect(elements.statusDiv.classList.contains('error')).toBe(true)
expect(elements.statusDiv.textContent).toContain('There was an error processing your request')
diff --git a/src/components/Pages/Downloads/client/__tests__/selectors.spec.ts b/src/components/Pages/Downloads/client/__tests__/selectors.spec.ts
index e719dcf58..9ecbce073 100644
--- a/src/components/Pages/Downloads/client/__tests__/selectors.spec.ts
+++ b/src/components/Pages/Downloads/client/__tests__/selectors.spec.ts
@@ -2,10 +2,8 @@ import { describe, expect, it } from 'vitest'
import { TestError } from '@test/errors'
import {
getDownloadButtonWrapper,
- getDownloadCompanyNameInput,
getDownloadFirstNameInput,
getDownloadFormElement,
- getDownloadJobTitleInput,
getDownloadLastNameInput,
getDownloadStatusDiv,
getDownloadSubmitButton,
@@ -177,18 +175,6 @@ const inputSelectorCases = [
id: 'workEmail',
errorMessage: 'Work email input not found',
},
- {
- name: 'job title',
- selector: getDownloadJobTitleInput,
- id: 'jobTitle',
- errorMessage: 'Job title input not found',
- },
- {
- name: 'company name',
- selector: getDownloadCompanyNameInput,
- id: 'companyName',
- errorMessage: 'Company name input not found',
- },
]
describe.each(inputSelectorCases)('$name selector', ({ selector, id, errorMessage }) => {
diff --git a/src/components/Pages/Downloads/client/__tests__/testUtils.ts b/src/components/Pages/Downloads/client/__tests__/testUtils.ts
index 1623299dc..1fbdeaf07 100644
--- a/src/components/Pages/Downloads/client/__tests__/testUtils.ts
+++ b/src/components/Pages/Downloads/client/__tests__/testUtils.ts
@@ -5,10 +5,8 @@ import DownloadFormFixture from '@components/Pages/Downloads/client/__tests__/__
import type { DownloadFormElement } from '@components/Pages/Downloads/client'
import {
getDownloadButtonWrapper,
- getDownloadCompanyNameInput,
getDownloadFirstNameInput,
getDownloadFormElement,
- getDownloadJobTitleInput,
getDownloadLastNameInput,
getDownloadStatusDiv,
getDownloadSubmitButton,
@@ -27,8 +25,6 @@ export interface DownloadFormElements {
firstName: HTMLInputElement
lastName: HTMLInputElement
workEmail: HTMLInputElement
- jobTitle: HTMLInputElement
- companyName: HTMLInputElement
}
export interface RenderDownloadFormContext {
@@ -67,8 +63,6 @@ export const renderDownloadForm = async (assertion: RenderDownloadFormAssertion)
firstName: getDownloadFirstNameInput(window.document),
lastName: getDownloadLastNameInput(window.document),
workEmail: getDownloadWorkEmailInput(window.document),
- jobTitle: getDownloadJobTitleInput(window.document),
- companyName: getDownloadCompanyNameInput(window.document),
},
}
diff --git a/src/components/Pages/Downloads/client/index.ts b/src/components/Pages/Downloads/client/index.ts
index e46c133fb..708eb1f8f 100644
--- a/src/components/Pages/Downloads/client/index.ts
+++ b/src/components/Pages/Downloads/client/index.ts
@@ -114,13 +114,15 @@ export class DownloadFormElement extends LitElement {
const dataSubjectId =
typeof dataSubjectIdRaw === 'string' ? dataSubjectIdRaw.trim() : ''
const DataSubjectId = dataSubjectId.length > 0 ? dataSubjectId : undefined
+ const jobTitle = String(formData.get('jobTitle') ?? '').trim()
+ const companyName = String(formData.get('companyName') ?? '').trim()
const payload = {
firstName: String(formData.get('firstName') ?? ''),
lastName: String(formData.get('lastName') ?? ''),
workEmail: String(formData.get('workEmail') ?? ''),
- jobTitle: String(formData.get('jobTitle') ?? ''),
- companyName: String(formData.get('companyName') ?? ''),
+ jobTitle: jobTitle || undefined,
+ companyName: companyName || undefined,
consent,
DataSubjectId,
} satisfies DownloadsSubmitInput
@@ -128,9 +130,9 @@ export class DownloadFormElement extends LitElement {
try {
const { error } = await actions.downloads.submit(payload)
- // @TODO: Improve this error handling to be more user friendly. Also this is inconsistent with how we're handling errors in other forms, where we set a message and return. Should look at the types of errors that could occur, and give the user an idea of what to do.
if (error) {
- throw new ClientScriptError({ message: error.message || 'Failed to submit form' })
+ this.showStatus('error', 'There was an error processing your request. Please try again.')
+ return
}
markEmailCollected(payload.workEmail.trim(), 'download_form')
diff --git a/src/components/Pages/Downloads/client/selectors.ts b/src/components/Pages/Downloads/client/selectors.ts
index b5a84f3a9..d7716c868 100644
--- a/src/components/Pages/Downloads/client/selectors.ts
+++ b/src/components/Pages/Downloads/client/selectors.ts
@@ -92,14 +92,6 @@ export function getDownloadWorkEmailInput(root?: SelectorRoot): HTMLInputElement
return queryInputElement('#workEmail', 'Work email input not found', root)
}
-export function getDownloadJobTitleInput(root?: SelectorRoot): HTMLInputElement {
- return queryInputElement('#jobTitle', 'Job title input not found', root)
-}
-
-export function getDownloadCompanyNameInput(root?: SelectorRoot): HTMLInputElement {
- return queryInputElement('#companyName', 'Company name input not found', root)
-}
-
export type DownloadFormInvalidatableControl =
| HTMLInputElement
| HTMLSelectElement
diff --git a/src/components/scripts/sentry/__tests__/helpers.spec.ts b/src/components/scripts/sentry/__tests__/helpers.spec.ts
index 8a9168888..179bea708 100644
--- a/src/components/scripts/sentry/__tests__/helpers.spec.ts
+++ b/src/components/scripts/sentry/__tests__/helpers.spec.ts
@@ -54,6 +54,78 @@ const createContactSubmitHttpErrorEvent = (): Parameters
[0]
+const createConsentRateLimitHttpErrorEvent = (): Parameters[0] =>
+ ({
+ type: 'error',
+ request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.consentCreate' },
+ exception: {
+ values: [
+ {
+ value: 'HTTP Client Error with status code: 429',
+ mechanism: {
+ type: 'auto.http.client.fetch',
+ handled: false,
+ },
+ },
+ ],
+ },
+ }) as unknown as Parameters[0]
+
+const createDownloadsSubmitHttpErrorEvent = (): Parameters[0] =>
+ ({
+ type: 'error',
+ request: { url: 'https://www.webstackbuilders.com/_actions/downloads.submit' },
+ exception: {
+ values: [
+ {
+ value: 'HTTP Client Error with status code: 400',
+ mechanism: {
+ type: 'auto.http.client.fetch',
+ handled: false,
+ },
+ },
+ ],
+ },
+ }) as unknown as Parameters[0]
+
+const createNewsletterSubscribeHttpErrorEvent = (): Parameters[0] =>
+ ({
+ type: 'error',
+ request: { url: 'https://www.webstackbuilders.com/_actions/newsletter.subscribe' },
+ exception: {
+ values: [
+ {
+ value: 'HTTP Client Error with status code: 500',
+ mechanism: {
+ type: 'auto.http.client.fetch',
+ handled: false,
+ },
+ },
+ ],
+ },
+ }) as unknown as Parameters[0]
+
+const createConsentLogRetryErrorEvent = (): Parameters[0] =>
+ ({
+ type: 'error',
+ message: 'Try again in 30s',
+ tags: {
+ scriptName: 'cookieConsent',
+ operation: 'logConsentToAPI',
+ },
+ exception: {
+ values: [
+ {
+ value: 'Try again in 30s',
+ mechanism: {
+ type: 'generic',
+ handled: true,
+ },
+ },
+ ],
+ },
+ }) as unknown as Parameters[0]
+
const createHint = (): Parameters[1] =>
({}) as Parameters[1]
@@ -103,6 +175,50 @@ describe('sentry helpers', () => {
expect(result).toBeNull()
})
+ it('drops handled consent rate-limit http client failures', () => {
+ isProdMock.mockReturnValue(true)
+ getConsentSnapshotMock.mockReturnValue({ analytics: true })
+
+ const event = createConsentRateLimitHttpErrorEvent()
+
+ const result = beforeSendHandler(event, createHint())
+
+ expect(result).toBeNull()
+ })
+
+ it('drops handled downloads action http client failures', () => {
+ isProdMock.mockReturnValue(true)
+ getConsentSnapshotMock.mockReturnValue({ analytics: true })
+
+ const event = createDownloadsSubmitHttpErrorEvent()
+
+ const result = beforeSendHandler(event, createHint())
+
+ expect(result).toBeNull()
+ })
+
+ it('drops handled newsletter action http client failures', () => {
+ isProdMock.mockReturnValue(true)
+ getConsentSnapshotMock.mockReturnValue({ analytics: true })
+
+ const event = createNewsletterSubscribeHttpErrorEvent()
+
+ const result = beforeSendHandler(event, createHint())
+
+ expect(result).toBeNull()
+ })
+
+ it('drops handled consent log retry errors', () => {
+ isProdMock.mockReturnValue(true)
+ getConsentSnapshotMock.mockReturnValue({ analytics: true })
+
+ const event = createConsentLogRetryErrorEvent()
+
+ const result = beforeSendHandler(event, createHint())
+
+ expect(result).toBeNull()
+ })
+
it('scrubs PII when analytics consent is missing and preserves safe breadcrumbs', () => {
isProdMock.mockReturnValue(true)
getConsentSnapshotMock.mockReturnValue({ analytics: false })
diff --git a/src/components/scripts/sentry/helpers.ts b/src/components/scripts/sentry/helpers.ts
index 6a32d0837..9f44ebf58 100644
--- a/src/components/scripts/sentry/helpers.ts
+++ b/src/components/scripts/sentry/helpers.ts
@@ -21,6 +21,63 @@ const isHandledContactSubmitHttpError = (event: Parameters[0]
)
}
+const isHandledConsentRateLimitHttpError = (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' &&
+ requestUrl.includes('/_actions/gdpr.consentCreate') &&
+ mechanismType === 'auto.http.client.fetch' &&
+ typeof errorMessage === 'string' &&
+ errorMessage.includes('HTTP Client Error with status code: 429')
+ )
+}
+
+const isHandledDownloadsSubmitHttpError = (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' &&
+ requestUrl.includes('/_actions/downloads.submit') &&
+ mechanismType === 'auto.http.client.fetch' &&
+ typeof errorMessage === 'string' &&
+ errorMessage.includes('HTTP Client Error with status code:')
+ )
+}
+
+const isHandledNewsletterSubscribeHttpError = (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' &&
+ requestUrl.includes('/_actions/newsletter.subscribe') &&
+ mechanismType === 'auto.http.client.fetch' &&
+ typeof errorMessage === 'string' &&
+ errorMessage.includes('HTTP Client Error with status code:')
+ )
+}
+
+const isHandledConsentLogRetryError = (event: Parameters[0]): boolean => {
+ const errorMessage = event.exception?.values?.[0]?.value ?? event.message ?? ''
+ const tags = event.tags ?? {}
+
+ return (
+ tags['scriptName'] === 'cookieConsent' &&
+ tags['operation'] === 'logConsentToAPI' &&
+ typeof errorMessage === 'string' &&
+ /try again in\s+\d+s/i.test(errorMessage)
+ )
+}
+
function scrubBreadcrumbs(
breadcrumbs: NonNullable[0]['breadcrumbs']>
) {
@@ -49,6 +106,30 @@ export const beforeSendHandler: BeforeSendHandler = (event, _hint) => {
return null
}
+ // Consent logging is best-effort on the client. Rate limiting here is expected
+ // under bursty preference changes, so drop the browser-side auto-fetch event.
+ if (isHandledConsentRateLimitHttpError(event)) {
+ return null
+ }
+
+ // The downloads form handles action failures in the UI. Drop the browser-side
+ // auto-fetch event and rely on the user-facing error state instead.
+ if (isHandledDownloadsSubmitHttpError(event)) {
+ return null
+ }
+
+ // The newsletter form handles action failures in the UI. Drop the browser-side
+ // auto-fetch event and rely on the server-side action error for diagnosis.
+ if (isHandledNewsletterSubscribeHttpError(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)) {
+ return null
+ }
+
const currentConsent = getConsentSnapshot()
if (!currentConsent.analytics) {
if (event.user) {
diff --git a/src/components/scripts/store/__tests__/consent.spec.ts b/src/components/scripts/store/__tests__/consent.spec.ts
index 71cd86983..5268e92d5 100644
--- a/src/components/scripts/store/__tests__/consent.spec.ts
+++ b/src/components/scripts/store/__tests__/consent.spec.ts
@@ -25,6 +25,7 @@ import {
initConsentSideEffects,
} from '@components/scripts/store/consent'
import { $isConsentBannerVisible } from '@components/scripts/store/consentBanner'
+import * as errorHandlerModule from '@components/scripts/errors/handler'
// Mock js-cookie
vi.mock('js-cookie', () => ({
@@ -61,6 +62,7 @@ vi.mock('@components/scripts/sentry/helpers', () => ({
}))
afterEach(() => {
+ vi.useRealTimers()
vi.restoreAllMocks()
vi.clearAllMocks()
vi.unstubAllGlobals()
@@ -473,6 +475,135 @@ describe('Consent side effects', () => {
onlineGetter.mockRestore()
})
+ it('coalesces burst consent updates into the latest payload before sending', async () => {
+ vi.useFakeTimers()
+
+ const fetchSpy = vi.fn().mockResolvedValue({ ok: true })
+ vi.stubGlobal('fetch', fetchSpy)
+
+ let consentListener:
+ | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void)
+ | undefined
+ vi.spyOn($consent, 'subscribe').mockImplementation(listener => {
+ consentListener = listener
+ return () => {}
+ })
+ vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {})
+ vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {})
+ vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {})
+
+ initConsentSideEffects()
+
+ const dataSubjectId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ const state0 = {
+ analytics: false,
+ marketing: false,
+ functional: false,
+ DataSubjectId: dataSubjectId,
+ }
+ const state1 = {
+ analytics: true,
+ marketing: false,
+ functional: false,
+ DataSubjectId: dataSubjectId,
+ }
+ const state2 = {
+ analytics: true,
+ marketing: true,
+ functional: false,
+ DataSubjectId: dataSubjectId,
+ }
+ const state3 = {
+ analytics: true,
+ marketing: true,
+ functional: true,
+ DataSubjectId: dataSubjectId,
+ }
+
+ await consentListener?.(state1, state0)
+ await consentListener?.(state2, state1)
+ await consentListener?.(state3, state2)
+
+ expect(fetchSpy).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(250)
+
+ await vi.waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
+ })
+
+ const firstFetchCall = fetchSpy.mock.calls.at(0)
+ if (!firstFetchCall) {
+ throw new TestError('Expected coalesced consent logging fetch to be called once')
+ }
+
+ const [, options] = firstFetchCall
+ const payload = JSON.parse(options?.body as string)
+ expect(payload.purposes).toEqual(['analytics', 'marketing', 'functional'])
+ })
+
+ it('retries consent logging after a 429 without reporting a script error', async () => {
+ vi.useFakeTimers()
+
+ const fetchSpy = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ headers: { get: vi.fn(() => '1') },
+ json: vi.fn().mockResolvedValue({ error: { message: 'Try again in 1s' } }),
+ })
+ .mockResolvedValueOnce({ ok: true })
+ vi.stubGlobal('fetch', fetchSpy)
+
+ const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError')
+
+ let consentListener:
+ | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void)
+ | undefined
+ vi.spyOn($consent, 'subscribe').mockImplementation(listener => {
+ consentListener = listener
+ return () => {}
+ })
+ vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {})
+ vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {})
+ vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {})
+
+ initConsentSideEffects()
+
+ const oldState = {
+ analytics: false,
+ marketing: false,
+ functional: false,
+ DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ }
+ const newState = {
+ analytics: true,
+ marketing: false,
+ functional: false,
+ DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
+ }
+
+ await consentListener?.(newState, oldState)
+
+ await vi.advanceTimersByTimeAsync(250)
+
+ await vi.waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
+ })
+
+ expect(handleScriptErrorSpy).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(1_000)
+
+ await vi.waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
+ })
+
+ expect(handleScriptErrorSpy).not.toHaveBeenCalled()
+ })
+
it('deletes the data subject id when functional consent is revoked', () => {
let functionalListener: ((_hasConsent: boolean) => void) | undefined
vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(listener => {
diff --git a/src/components/scripts/store/consent.ts b/src/components/scripts/store/consent.ts
index 71ba36092..4012a20fb 100644
--- a/src/components/scripts/store/consent.ts
+++ b/src/components/scripts/store/consent.ts
@@ -33,6 +33,18 @@ export interface ConsentState {
const consentCookieCategories: ConsentCategories[] = ['analytics', 'marketing', 'functional']
const CONSENT_COOKIE_PREFIX = 'consent_'
+const CONSENT_LOG_DEBOUNCE_MS = 250
+const CONSENT_LOG_MAX_RETRY_DELAY_MS = 30_000
+
+class ConsentLogRetryableError extends Error {
+ readonly retryAfterMs: number
+
+ constructor(message: string, retryAfterMs: number, cause?: unknown) {
+ super(message, { cause })
+ this.name = 'ConsentLogRetryableError'
+ this.retryAfterMs = retryAfterMs
+ }
+}
const prefixConsentCookie = (category: ConsentCategories): string =>
`${CONSENT_COOKIE_PREFIX}${category}`
@@ -371,13 +383,64 @@ export function initConsentSideEffects(): void {
userAgent: string
verified: boolean
}
- const pendingConsentLogQueue: ConsentLogPayload[] = []
+ let queuedConsentLogPayload: ConsentLogPayload | null = null
let hasConsentLoggingFailure = false
let isConsentLogProcessing = false
let onlineListener: (() => void) | null = null
+ let consentLogTimerId: number | null = null
const isNavigatorOnline = () => typeof navigator === 'undefined' || navigator.onLine !== false
+ const clearConsentLogTimer = () => {
+ if (consentLogTimerId === null || typeof window === 'undefined') {
+ return
+ }
+
+ window.clearTimeout(consentLogTimerId)
+ consentLogTimerId = null
+ }
+
+ const scheduleConsentLogProcessing = (delayMs: number) => {
+ if (typeof window === 'undefined') {
+ void processConsentLogQueue()
+ return
+ }
+
+ clearConsentLogTimer()
+ consentLogTimerId = window.setTimeout(() => {
+ consentLogTimerId = null
+ void processConsentLogQueue()
+ }, delayMs)
+ }
+
+ const parseRetryAfterMs = (response: Response, serverMessage?: string): number => {
+ const retryAfterHeader = response.headers.get('Retry-After')
+ if (retryAfterHeader) {
+ const retryAfterSeconds = Number(retryAfterHeader)
+ if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
+ return Math.min(retryAfterSeconds * 1000, CONSENT_LOG_MAX_RETRY_DELAY_MS)
+ }
+
+ const retryAfterDate = Date.parse(retryAfterHeader)
+ if (!Number.isNaN(retryAfterDate)) {
+ return Math.min(
+ Math.max(0, retryAfterDate - Date.now()),
+ CONSENT_LOG_MAX_RETRY_DELAY_MS
+ )
+ }
+ }
+
+ const retryMatch = serverMessage?.match(/try again in\s+(\d+)s/i)
+ if (retryMatch) {
+ const retryAfterSeconds = Number(retryMatch[1])
+ if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
+ return Math.min(retryAfterSeconds * 1000, CONSENT_LOG_MAX_RETRY_DELAY_MS)
+ }
+ }
+
+ return 5_000
+ }
+
const ensureOnlineListener = () => {
if (typeof window === 'undefined' || onlineListener) {
return
@@ -407,26 +470,34 @@ export function initConsentSideEffects(): void {
isConsentLogProcessing = true
try {
- while (pendingConsentLogQueue.length > 0) {
- const payload = pendingConsentLogQueue[0]!
+ while (queuedConsentLogPayload) {
+ const payload = queuedConsentLogPayload
+ queuedConsentLogPayload = null
+
try {
await sendConsentPayload(payload)
- pendingConsentLogQueue.shift()
} catch (error) {
if (!isNavigatorOnline()) {
+ queuedConsentLogPayload ??= payload
ensureOnlineListener()
break
}
+ if (error instanceof ConsentLogRetryableError) {
+ queuedConsentLogPayload ??= payload
+ scheduleConsentLogProcessing(error.retryAfterMs)
+ break
+ }
+
hasConsentLoggingFailure = true
- pendingConsentLogQueue.shift()
handleScriptError(error, consentLoggingContext)
+ break
}
}
} finally {
isConsentLogProcessing = false
- if (pendingConsentLogQueue.length === 0 && onlineListener && typeof window !== 'undefined') {
+ if (!queuedConsentLogPayload && onlineListener && typeof window !== 'undefined') {
window.removeEventListener('online', onlineListener)
onlineListener = null
}
@@ -434,8 +505,8 @@ export function initConsentSideEffects(): void {
}
const enqueueConsentPayload = (payload: ConsentLogPayload) => {
- pendingConsentLogQueue.push(payload)
- void processConsentLogQueue()
+ queuedConsentLogPayload = payload
+ scheduleConsentLogProcessing(CONSENT_LOG_DEBOUNCE_MS)
}
const sendConsentPayload = async (payload: ConsentLogPayload) => {
@@ -457,6 +528,18 @@ export function initConsentSideEffects(): void {
? (responseBody as { message: string }).message
: undefined)
+ if (response.status === 429) {
+ throw new ConsentLogRetryableError(
+ serverMessage ?? 'Consent logging is temporarily rate limited',
+ parseRetryAfterMs(response, serverMessage),
+ {
+ status: response.status,
+ statusText: response.statusText,
+ body: responseBody,
+ }
+ )
+ }
+
throw new ClientScriptError({
message: serverMessage ?? `Failed to record consent (status ${response.status})`,
cause: {