From d82ede7b851bfa80dc3ab043a7fdb1c184aaa42b Mon Sep 17 00:00:00 2001 From: bymyself Date: Sun, 23 Aug 2026 05:43:29 +0000 Subject: [PATCH 1/3] feat: surface 429 Retry-After as cooldown toast on invite resend WorkspaceApiError gains a retryAfter field parsed from the Retry-After header (integer seconds only). handleResendInvite in useMembersPanel now shows a dismissible warn toast with pluralised countdown instead of the generic error toast when the server rate-limits. Co-Authored-By: Claude Sonnet 4.6 --- src/locales/en/main.json | 2 ++ src/platform/workspace/api/workspaceApi.ts | 16 +++++++++++-- .../workspace/composables/useMembersPanel.ts | 23 +++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index bcdfed150d5..f13ed7f017e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -3303,6 +3303,8 @@ }, "inviteResent": "Invite resent", "inviteResendFailed": "Failed to resend invite", + "inviteResendCooldown": "You're resending invites too quickly", + "inviteResendCooldownDetail": "You can resend in {seconds} second | You can resend in {seconds} seconds", "failedToUpdateWorkspace": "Failed to update workspace", "failedToCreateWorkspace": "Failed to create workspace", "failedToDeleteWorkspace": "Failed to delete workspace", diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 30c3c3104e3..7dd67be5c82 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -160,13 +160,20 @@ export class WorkspaceApiError extends Error { constructor( message: string, public readonly status?: number, - public readonly code?: string + public readonly code?: string, + public readonly retryAfter?: number ) { super(message) this.name = 'WorkspaceApiError' } } +function parseRetryAfterSeconds(value: unknown): number | undefined { + if (typeof value !== 'string' || value.trim() === '') return undefined + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined +} + const workspaceApiClient = axios.create({ headers: { 'Content-Type': 'application/json' @@ -189,10 +196,15 @@ function handleAxiosError(err: unknown): never { ) // Callers compare `code` against server-defined values, so the parser's // "no code reported" sentinel must stay out of that contract. + const retryAfter = + status === 429 + ? parseRetryAfterSeconds(err.response?.headers?.['retry-after']) + : undefined throw new WorkspaceApiError( message, status, - code === UNKNOWN_ERROR_CODE ? undefined : code + code === UNKNOWN_ERROR_CODE ? undefined : code, + retryAfter ) } throw err diff --git a/src/platform/workspace/composables/useMembersPanel.ts b/src/platform/workspace/composables/useMembersPanel.ts index aa51c8b9ae2..9e9c3a6b7d9 100644 --- a/src/platform/workspace/composables/useMembersPanel.ts +++ b/src/platform/workspace/composables/useMembersPanel.ts @@ -8,7 +8,10 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useBillingContext } from '@/composables/billing/useBillingContext' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' -import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi' +import { + WorkspaceApiError, + type WorkspaceRole +} from '@/platform/workspace/api/workspaceApi' import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import type { @@ -349,7 +352,23 @@ export function useMembersPanel() { summary: t('workspacePanel.toast.inviteResent'), life: 2000 }) - } catch { + } catch (err) { + if (err instanceof WorkspaceApiError && err.status === 429) { + const seconds = err.retryAfter + toast.add({ + severity: 'warn', + summary: t('workspacePanel.toast.inviteResendCooldown'), + detail: seconds + ? t( + 'workspacePanel.toast.inviteResendCooldownDetail', + { seconds }, + seconds + ) + : undefined, + life: Math.min((seconds ?? 5) * 1000, 10_000) + }) + return + } toast.add({ severity: 'error', summary: t('workspacePanel.toast.inviteResendFailed') From 07008becb329d9ee8d30cfa412c3c4b5a59a96f7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 23 Aug 2026 05:47:44 +0000 Subject: [PATCH 2/3] [automated] Apply ESLint and Oxfmt fixes --- src/platform/workspace/api/workspaceApi.ts | 4 +++- src/platform/workspace/composables/useMembersPanel.ts | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 7dd67be5c82..08eaa10ef0f 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -171,7 +171,9 @@ export class WorkspaceApiError extends Error { function parseRetryAfterSeconds(value: unknown): number | undefined { if (typeof value !== 'string' || value.trim() === '') return undefined const seconds = Number(value) - return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined + return Number.isFinite(seconds) && seconds > 0 + ? Math.ceil(seconds) + : undefined } const workspaceApiClient = axios.create({ diff --git a/src/platform/workspace/composables/useMembersPanel.ts b/src/platform/workspace/composables/useMembersPanel.ts index 9e9c3a6b7d9..dc6fa4ddde1 100644 --- a/src/platform/workspace/composables/useMembersPanel.ts +++ b/src/platform/workspace/composables/useMembersPanel.ts @@ -8,10 +8,8 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useBillingContext } from '@/composables/billing/useBillingContext' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' -import { - WorkspaceApiError, - type WorkspaceRole -} from '@/platform/workspace/api/workspaceApi' +import { WorkspaceApiError } from '@/platform/workspace/api/workspaceApi' +import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi' import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import type { From e4c7eb0418034aa9e5081645c46b13c7c26b07e1 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 23 Aug 2026 01:17:43 -0700 Subject: [PATCH 3/3] test: preserve vue-i18n actual exports in useMembersPanel mock The new WorkspaceApiError import chain pulls src/i18n.ts, which needs the real createI18n. Use importOriginal so the mock only overrides useI18n. --- .../workspace/composables/useMembersPanel.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/platform/workspace/composables/useMembersPanel.test.ts b/src/platform/workspace/composables/useMembersPanel.test.ts index 4efb8798906..755a0d0a1a3 100644 --- a/src/platform/workspace/composables/useMembersPanel.test.ts +++ b/src/platform/workspace/composables/useMembersPanel.test.ts @@ -324,9 +324,13 @@ vi.mock('primevue/usetoast', () => ({ useToast: () => ({ add: mockToastAdd }) })) -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }) -})) +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + useI18n: () => ({ t: (key: string) => key }) + } +}) vi.mock('pinia', async (importOriginal) => { const actual = await importOriginal()