From 42ca11f89e7649402a303fd561bddaa9982c4107 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:58:48 +0200 Subject: [PATCH] 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. ## 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. --------- Co-authored-by: Claude Sonnet 4.6 --- .../interfaces/Support/SupportForm.state.ts | 4 +++- .../interfaces/Support/SupportFormPage.tsx | 7 ++++--- .../interfaces/Support/SupportFormV2.tsx | 1 + .../interfaces/Support/SupportFormV3.tsx | 1 + .../interfaces/Support/SupportSidebarForm.tsx | 4 +++- .../data/feedback/support-ticket-send.ts | 21 +++++++++++++++++-- apps/studio/data/fetchers.ts | 12 ++++++----- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/studio/components/interfaces/Support/SupportForm.state.ts b/apps/studio/components/interfaces/Support/SupportForm.state.ts index c9895322cbc..3caac18083d 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.state.ts +++ b/apps/studio/components/interfaces/Support/SupportForm.state.ts @@ -32,6 +32,7 @@ export type SupportFormState = | { type: 'error' message: string + code?: number } export type SupportFormActions = @@ -45,7 +46,7 @@ export type SupportFormActions = submittedRequest: SubmittedSupportRequest debugSource?: string } - | { type: 'ERROR'; message: string; debugSource?: string } + | { type: 'ERROR'; message: string; code?: number; debugSource?: string } | { type: 'RETURN_TO_EDITING'; debugSource?: string } export function createInitialSupportFormState(): SupportFormState { @@ -89,6 +90,7 @@ export function supportFormReducer( return { type: 'error', message: action.message, + code: action.code, } } console.warn( diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index 0f675c3f165..8181283f5b7 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -82,7 +82,9 @@ function SupportFormPageContent() { useStateTransition(state, 'submitting', 'error', (_, curr) => { toast.error(`Failed to submit support ticket: ${curr.message}`) - Sentry.captureMessage(`Failed to submit Support Form: ${curr.message}`) + if (curr.code !== 429) { + Sentry.captureMessage(`Failed to submit Support Form: ${curr.message}`) + } dispatch({ type: 'RETURN_TO_EDITING' }) }) @@ -258,8 +260,7 @@ function SupportFormBody({ return (
diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 5b60aa92a1d..17d6ad0e884 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -114,6 +114,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo dispatch({ type: 'ERROR', message: error.message, + code: error.code, }) }, }) diff --git a/apps/studio/components/interfaces/Support/SupportFormV3.tsx b/apps/studio/components/interfaces/Support/SupportFormV3.tsx index c86f463cc09..553dd433d88 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV3.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV3.tsx @@ -122,6 +122,7 @@ export const SupportFormV3 = ({ dispatch({ type: 'ERROR', message: error.message, + code: error.code, }) }, }) diff --git a/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx b/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx index 40b0288d6ea..fa910e1db52 100644 --- a/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx +++ b/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx @@ -70,7 +70,9 @@ export function SupportForm({ initialParams }: SupportFormProps) { useStateTransition(state, 'submitting', 'error', (_, curr) => { toast.error(`Failed to submit support ticket: ${curr.message}`) - Sentry.captureMessage(`Failed to submit Support Form: ${curr.message}`) + if (curr.code !== 429) { + Sentry.captureMessage(`Failed to submit Support Form: ${curr.message}`) + } dispatch({ type: 'RETURN_TO_EDITING' }) }) diff --git a/apps/studio/data/feedback/support-ticket-send.ts b/apps/studio/data/feedback/support-ticket-send.ts index 8e14bf50b40..37957dce243 100644 --- a/apps/studio/data/feedback/support-ticket-send.ts +++ b/apps/studio/data/feedback/support-ticket-send.ts @@ -5,7 +5,8 @@ import { toast } from 'sonner' import type { ExtendedSupportCategories } from '@/components/interfaces/Support/Support.constants' import { handleError, post } from '@/data/fetchers' -import type { ResponseError, UseCustomMutationOptions } from '@/types' +import { ResponseError } from '@/types' +import type { UseCustomMutationOptions } from '@/types' export type sendSupportTicketVariables = { subject: string @@ -25,6 +26,8 @@ export type sendSupportTicketVariables = { dashboardStudioVersion?: string } +const RATE_LIMIT_FALLBACK_SECONDS = 60 + export async function sendSupportTicket({ subject, message, @@ -42,7 +45,7 @@ export async function sendSupportTicket({ dashboardLogs, dashboardStudioVersion, }: sendSupportTicketVariables) { - const { data, error } = await post('/platform/feedback/send', { + const { data, error, response } = await post('/platform/feedback/send', { body: { subject, message, @@ -65,6 +68,20 @@ export async function sendSupportTicket({ }) if (error) { + const httpResponse: unknown = response + if (httpResponse instanceof Response && httpResponse.status === 429) { + const resetHeader = + httpResponse.headers.get('Retry-After') ?? httpResponse.headers.get('X-RateLimit-Reset') + const parsedReset = resetHeader ? parseInt(resetHeader, 10) : NaN + const waitSeconds = Number.isFinite(parsedReset) ? parsedReset : RATE_LIMIT_FALLBACK_SECONDS + throw new ResponseError( + `You have submitted too many support requests. Please try again in ${waitSeconds} second${waitSeconds === 1 ? '' : 's'}.`, + 429, + undefined, + waitSeconds + ) + } + handleError(error, { alwaysCapture: true, sentryContext: { diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 7e71e9dd686..1ce51f26e80 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -140,7 +140,8 @@ client.use( body.requestId = request.headers.get('X-Request-Id') - const retryAfterHeader = response.headers.get('Retry-After') + const retryAfterHeader = + response.headers.get('Retry-After') ?? response.headers.get('X-RateLimit-Reset') body.retryAfter = retryAfterHeader ? parseInt(retryAfterHeader) : undefined const requestUrl = new URL(request.url) @@ -296,10 +297,11 @@ async function handleFetchError(response: unknown): Promise { resJson.msg ?? resJson.error ?? `An error has occurred: ${status ?? 'Unknown error'}` - const retryAfter = - response instanceof Response && response.headers.get('Retry-After') - ? parseInt(response.headers.get('Retry-After')!) - : undefined + const retryAfterHeader = + response instanceof Response + ? (response.headers.get('Retry-After') ?? response.headers.get('X-RateLimit-Reset')) + : null + const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader) : undefined let error = new ResponseError(message, status, undefined, retryAfter)