Files
supabase/apps/studio/components/interfaces/Support/SupportFormV2.tsx
Jordi EnricandClaude Sonnet 4.6 42ca11f89e fix(support): handle rate-limited support submissions gracefully (#46928)
## Problem

When a support ticket submission is rejected by the API's rate limiter,
the form surfaced the raw server exception text to the user and reported
every rejection as an application error. This produced a steady stream
of noisy error reports for what is actually expected, recoverable
behavior.

The rejections are not random: the submit endpoint allows only a small
number of requests in a short window, so a quick second submission (a
fast retry or a follow-up ticket moments later) gets rejected. The first
submission usually succeeds; it's the immediate follow-up that fails.
Surfacing the raw error and logging it made this look worse than it is.

Separately, the success screen had only top padding, leaving its actions
flush against the bottom edge of the card.

## Fix

- Detect the rate-limit response and show a clear, friendly message that
tells the user how long to wait before trying again, instead of the raw
exception text.
- Stop reporting rate-limit rejections as errors to our monitoring. They
are expected and recoverable, so they no longer add noise.
- Give the success state the same vertical padding as the rest of the
form so its actions are not flush against the card edge.

## How to test

- Open the support form and simulate a 429 from the submit endpoint.
- Expected: a friendly message telling the user when they can retry, and
no error reported to monitoring.
- Submit a ticket successfully and confirm the success screen has even
padding above and below its content.

## Notes

This covers the user-facing handling. The rate-limit threshold itself is
tuned conservatively on the API and can be revisited separately so that
ordinary, legitimate resubmissions are not caught.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved support form handling for rate-limited (429) submissions by
suppressing unnecessary error reporting while still showing the error
and returning the form to editing.
* Fixed inconsistent support form spacing so padding is consistent
regardless of submission outcome.
* **Improvements**
* Propagated backend error `code` through the support-ticket submission
flow so the UI can react more intelligently to failures (including 429
retry-window messaging).
* Enhanced retry timing extraction for rate-limited errors by using
`Retry-After` with a fallback to rate-limit reset data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:58:48 +00:00

275 lines
9.7 KiB
TypeScript

// End of third-party imports
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { useConstant, useFlag } from 'common'
import { CLIENT_LIBRARIES } from 'common/constants'
import { type Dispatch, type MouseEventHandler } from 'react'
import type { SubmitHandler, UseFormReturn } from 'react-hook-form'
import { DialogSectionSeparator, Form } from 'ui'
import {
AffectedServicesSelector,
CATEGORIES_WITHOUT_AFFECTED_SERVICES,
} from './AffectedServicesSelector'
import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload'
import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo'
import { ClientLibraryInfo } from './ClientLibraryInfo'
import {
DASHBOARD_LOG_CATEGORIES,
getSanitizedBreadcrumbs,
uploadDashboardLog,
} from './dashboard-logs'
import { DashboardLogsToggle } from './DashboardLogsToggle'
import { MessageField } from './MessageField'
import { OrganizationSelector } from './OrganizationSelector'
import { ProjectAndPlanInfo } from './ProjectAndPlanInfo'
import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo'
import { SubmitButton } from './SubmitButton'
import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle'
import type { SupportFormValues } from './SupportForm.schema'
import type { SupportFormActions, SupportFormState } from './SupportForm.state'
import {
formatMessage,
formatStudioVersion,
getOrgSubscriptionPlan,
NO_ORG_MARKER,
NO_PROJECT_MARKER,
} from './SupportForm.utils'
import { getProjectAuthConfig } from '@/data/auth/auth-config-query'
import { useSendSupportTicketMutation } from '@/data/feedback/support-ticket-send'
import { type OrganizationPlanID } from '@/data/organizations/organization-query'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useGenerateAttachmentURLsMutation } from '@/data/support/generate-attachment-urls-mutation'
import { useDeploymentCommitQuery } from '@/data/utils/deployment-commit-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { detectBrowser } from '@/lib/helpers'
import { useProfile } from '@/lib/profile'
const useIsSimplifiedForm = (slug: string, subscriptionPlanId?: OrganizationPlanID) => {
const simplifiedSupportForm = useFlag('simplifiedSupportForm')
if (subscriptionPlanId === 'platform') {
return true
}
if (typeof simplifiedSupportForm === 'string') {
const slugs = (simplifiedSupportForm as string).split(',').map((x) => x.trim())
return slugs.includes(slug)
}
return false
}
interface SupportFormV2Props {
form: UseFormReturn<SupportFormValues>
initialError: string | null
state: SupportFormState
dispatch: Dispatch<SupportFormActions>
}
export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFormV2Props) => {
const { profile } = useProfile()
const respondToEmail = profile?.primary_email ?? 'your email'
const { organizationSlug, projectRef, category, severity, subject, library } = form.watch()
const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug
const selectedProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef
const { data: organizations } = useOrganizationsQuery()
const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug)
const simplifiedSupportForm = useIsSimplifiedForm(organizationSlug, subscriptionPlanId)
const showClientLibraries = useIsFeatureEnabled('support:show_client_libraries')
const attachmentUpload = useAttachmentUpload()
const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation()
const sanitizedLogSnapshot = useConstant(getSanitizedBreadcrumbs)
const { data: commit } = useDeploymentCommitQuery({
staleTime: 1000 * 60 * 10, // 10 minutes
})
const { mutate: submitSupportTicket } = useSendSupportTicketMutation({
onSuccess: (_, variables) => {
dispatch({
type: 'SUCCESS',
sentProjectRef: variables.projectRef,
sentOrgSlug: variables.organizationSlug,
sentCategory: variables.category,
submittedRequest: {
organizationSlug: variables.organizationSlug,
projectRef: variables.projectRef,
category: variables.category,
severity: variables.severity,
subject: variables.subject,
message: variables.message,
affectedServices: variables.affectedServices ?? '',
library: variables.library,
allowSupportAccess: variables.allowSupportAccess,
dashboardLogs: variables.dashboardLogs,
},
})
},
onError: (error) => {
dispatch({
type: 'ERROR',
message: error.message,
code: error.code,
})
},
})
const onSubmit: SubmitHandler<SupportFormValues> = async (formValues) => {
// Library is required when selecting "APIs and Client Libraries" category,
// but only when the library selector is visible (not in simplified form)
if (
!simplifiedSupportForm &&
showClientLibraries &&
formValues.category === SupportCategories.PROBLEM &&
!formValues.library
) {
form.setError('library', {
type: 'manual',
message: "Please select the library that you're facing issues with",
})
return
}
dispatch({ type: 'SUBMIT' })
const { attachDashboardLogs: formAttachDashboardLogs, ...values } = formValues
const attachDashboardLogs =
formAttachDashboardLogs && DASHBOARD_LOG_CATEGORIES.includes(values.category)
const [attachments, dashboardLogUrl] = await Promise.all([
attachmentUpload.createAttachments(),
attachDashboardLogs
? uploadDashboardLog({
userId: profile?.gotrue_id,
sanitizedLogs: sanitizedLogSnapshot,
uploadDashboardLogFn,
})
: undefined,
])
const selectedLibrary = values.library
? CLIENT_LIBRARIES.find((library) => library.language === values.library)
: undefined
const payload = {
...values,
organizationSlug: values.organizationSlug ?? NO_ORG_MARKER,
projectRef: values.projectRef ?? NO_PROJECT_MARKER,
allowSupportAccess:
values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category)
? values.allowSupportAccess
: false,
library:
values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined
? selectedLibrary.key
: '',
message: formatMessage({
message: values.message,
attachments,
error: initialError,
}),
verified: true,
tags: ['dashboard-support-form'],
siteUrl: '',
additionalRedirectUrls: '',
affectedServices: CATEGORIES_WITHOUT_AFFECTED_SERVICES.includes(values.category)
? ''
: values.affectedServices
.split(',')
.map((x) => x.trim().replace(/ /g, '_').toLowerCase())
.join(';'),
browserInformation: detectBrowser(),
dashboardLogs: dashboardLogUrl?.[0],
dashboardStudioVersion: commit ? formatStudioVersion(commit) : undefined,
}
if (values.projectRef !== NO_PROJECT_MARKER) {
try {
const authConfig = await getProjectAuthConfig({
projectRef: values.projectRef,
})
payload.siteUrl = authConfig.SITE_URL
payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST
} catch {
// [Joshen] No error handler required as fetching these info are nice to haves, not necessary
}
}
submitSupportTicket(payload)
}
const handleFormSubmit = form.handleSubmit(onSubmit)
const handleSubmitButtonClick: MouseEventHandler<HTMLButtonElement> = (event) => {
handleFormSubmit(event)
}
return (
<Form {...form}>
<form id="support-form" className="flex flex-col gap-y-6">
<h3 className="px-6 text-xl">How can we help?</h3>
<div className="px-6 flex flex-col gap-y-8">
<OrganizationSelector form={form} orgSlug={organizationSlug} />
<ProjectAndPlanInfo
form={form}
orgSlug={selectedOrgSlug}
projectRef={selectedProjectRef}
subscriptionPlanId={subscriptionPlanId}
category={category}
/>
<CategoryAndSeverityInfo
form={form}
category={category}
severity={severity}
projectRef={projectRef}
/>
</div>
<DialogSectionSeparator />
<div className="px-6 flex flex-col gap-y-8">
<SubjectAndSuggestionsInfo form={form} subject={subject} category={category} />
{!simplifiedSupportForm && (
<>
<ClientLibraryInfo form={form} library={library} category={category} />
<AffectedServicesSelector form={form} category={category} />
</>
)}
<MessageField form={form} originalError={initialError} />
<AttachmentUploadDisplay {...attachmentUpload} />
</div>
<DialogSectionSeparator />
{DASHBOARD_LOG_CATEGORIES.includes(category) && (
<>
<DashboardLogsToggle form={form} sanitizedLog={sanitizedLogSnapshot} className="px-6" />
<DialogSectionSeparator />
</>
)}
{!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && (
<>
<SupportAccessToggle form={form} className="px-6" />
<DialogSectionSeparator />
</>
)}
<div className="px-6 pt-2">
<SubmitButton
isSubmitting={state.type === 'submitting'}
userEmail={respondToEmail}
onClick={handleSubmitButtonClick}
/>
</div>
</form>
</Form>
)
}