diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 929e36008..cca6ba5be 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -729,6 +729,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/TooManyRequests" get: operationId: getAccountSession tags: @@ -5957,6 +5959,13 @@ components: msg: Unsupported media type TooManyRequests: description: Too many requests. + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + format: int64 + minimum: 1 content: application/json: schema: diff --git a/spx-gui/src/apis/account/index.test.ts b/spx-gui/src/apis/account/index.test.ts new file mode 100644 index 000000000..fc1a9e523 --- /dev/null +++ b/spx-gui/src/apis/account/index.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ApiException, ApiExceptionCode } from '@/apis/common/exception' + +import { getPasswordSignInRetryAfter } from '.' + +function makeApiException(code: ApiExceptionCode, meta: unknown) { + return new ApiException(code, 'request failed', { + req: new Request('https://account.example.com/session', { method: 'POST' }), + meta + }) +} + +describe('getPasswordSignInRetryAfter', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-14T00:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns a future retry time for generic request throttling', () => { + const retryAfter = Date.now() + 30_000 + + expect(getPasswordSignInRetryAfter(makeApiException(ApiExceptionCode.errorTooManyRequests, { retryAfter }))).toBe( + retryAfter + ) + }) + + it('rejects unrelated and invalid retry metadata', () => { + expect( + getPasswordSignInRetryAfter( + makeApiException(ApiExceptionCode.errorRateLimitExceeded, { retryAfter: Date.now() + 30_000 }) + ) + ).toBeNull() + expect( + getPasswordSignInRetryAfter(makeApiException(ApiExceptionCode.errorTooManyRequests, { retryAfter: null })) + ).toBeNull() + expect( + getPasswordSignInRetryAfter(makeApiException(ApiExceptionCode.errorTooManyRequests, { retryAfter: Date.now() })) + ).toBeNull() + }) +}) diff --git a/spx-gui/src/apis/account/index.ts b/spx-gui/src/apis/account/index.ts index 9f5011de3..d3de16046 100644 --- a/spx-gui/src/apis/account/index.ts +++ b/spx-gui/src/apis/account/index.ts @@ -1,6 +1,6 @@ // Account APIs for app account. -import { ApiException, ApiExceptionCode } from '@/apis/common/exception' +import { ApiException, ApiExceptionCode, type RetryAfterMeta } from '@/apis/common/exception' import { accountClient, type AccountIdentityProvider, type AccountSession, type AccountUser } from './common' import { DefaultException } from '@/utils/exception/base' @@ -33,6 +33,12 @@ export type PasswordSignInPayload = { password: string } +export function getPasswordSignInRetryAfter(error: unknown): number | null { + if (!(error instanceof ApiException) || error.code !== ApiExceptionCode.errorTooManyRequests) return null + const retryAfter = (error.meta as RetryAfterMeta | null)?.retryAfter + return typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > Date.now() ? retryAfter : null +} + export async function createSessionWithPassword(payload: PasswordSignInPayload): Promise { return (await accountClient .post('/session', { diff --git a/spx-gui/src/apis/common/client.test.ts b/spx-gui/src/apis/common/client.test.ts index cdee7ae43..b67ccbbd2 100644 --- a/spx-gui/src/apis/common/client.test.ts +++ b/spx-gui/src/apis/common/client.test.ts @@ -51,6 +51,19 @@ function makeRateLimitExceededResponse(retryAfter: string) { ) } +function makeTooManyRequestsResponse(retryAfter: string) { + return makeJsonResponse( + { + code: ApiExceptionCode.errorTooManyRequests, + msg: 'Too many requests' + }, + 429, + { + 'Retry-After': retryAfter + } + ) +} + function makeOAuthErrorResponse() { return makeJsonResponse( { @@ -130,6 +143,15 @@ describe('Client', () => { }) describe('retry-after metadata', () => { + it('should parse retry-after metadata for generic request throttling', async () => { + fetchMock.mockResolvedValueOnce(makeTooManyRequestsResponse('5')) + + const e = await expectApiException(client.post('/account/session'), ApiExceptionCode.errorTooManyRequests) + expect(e.meta).toMatchObject({ + retryAfter: expect.any(Number) + }) + }) + it('should parse retry-after metadata for rate limits', async () => { fetchMock.mockResolvedValueOnce(makeRateLimitExceededResponse('2')) diff --git a/spx-gui/src/components/account/sign-in/SignInForm.vue b/spx-gui/src/components/account/sign-in/SignInForm.vue index f825f4c12..cf7019c26 100644 --- a/spx-gui/src/components/account/sign-in/SignInForm.vue +++ b/spx-gui/src/components/account/sign-in/SignInForm.vue @@ -24,6 +24,7 @@ import { createSessionWithPassword, deleteSession, getIdentityProviders, + getPasswordSignInRetryAfter, getSession } from '@/apis/account' import type { IdentityProvider, OAuthRequest, PasswordSignInPayload } from '@/apis/account' @@ -89,6 +90,7 @@ const { fn: handleSwitchAccount, isLoading: isSwitchingAccount } = useMessageHan ) const isRedirectingToProvider = ref(false) +const passwordSignInRetryAfter = ref(null) function handleSignInWithProvider(provider: IdentityProvider) { markPendingAuthorization(props.request) @@ -98,7 +100,14 @@ function handleSignInWithProvider(provider: IdentityProvider) { const { fn: handleSignInWithPasswordSubmit, isLoading: isSubmittingSignInWithPassword } = useMessageHandle( async (payload: PasswordSignInPayload) => { - await createSessionWithPassword(payload) + try { + await createSessionWithPassword(payload) + } catch (error) { + const retryAfter = getPasswordSignInRetryAfter(error) + if (retryAfter == null) throw error + passwordSignInRetryAfter.value = retryAfter + return + } completeSignInWithCurrentAccount() }, { en: 'Failed to sign in', zh: '登录失败' } @@ -167,6 +176,7 @@ const { fn: handleSignInWithPasswordSubmit, isLoading: isSubmittingSignInWithPas diff --git a/spx-gui/src/components/account/sign-in/password-section/PasswordSection.test.ts b/spx-gui/src/components/account/sign-in/password-section/PasswordSection.test.ts new file mode 100644 index 000000000..ae4f31216 --- /dev/null +++ b/spx-gui/src/components/account/sign-in/password-section/PasswordSection.test.ts @@ -0,0 +1,76 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createI18n } from '@/utils/i18n' + +import PasswordSection from './PasswordSection.vue' + +describe('PasswordSection', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('disables submission and counts down until retry is allowed', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-14T00:00:00Z')) + const wrapper = mount(PasswordSection, { + props: { + isSubmitting: false, + retryAfter: Date.now() + 61_000 + }, + global: { + plugins: [createI18n({ lang: 'en' })] + } + }) + const submitButton = wrapper.get('button[type="submit"]') + + expect(submitButton.attributes('disabled')).toBeDefined() + expect(submitButton.text()).toBe('Try again in 01:01') + expect(wrapper.get('input[type="text"]').attributes('disabled')).toBeUndefined() + expect(wrapper.get('input[type="password"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('input[type="text"]').setValue('alice') + await wrapper.get('input[type="password"]').setValue('password') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.emitted('submit')).toBeUndefined() + + await vi.advanceTimersByTimeAsync(1000) + expect(submitButton.text()).toBe('Try again in 01:00') + + await vi.advanceTimersByTimeAsync(60_000) + expect(submitButton.attributes('disabled')).toBeUndefined() + expect(submitButton.text()).toBe('Sign In') + + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.emitted('submit')).toEqual([[{ username: 'alice', password: 'password' }]]) + + wrapper.unmount() + }) + + it('does not submit while a request is in progress', async () => { + const wrapper = mount(PasswordSection, { + props: { + isSubmitting: true, + retryAfter: null + }, + global: { + plugins: [createI18n({ lang: 'en' })] + } + }) + + await wrapper.get('input[type="text"]').setValue('alice') + await wrapper.get('input[type="password"]').setValue('password') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.emitted('submit')).toBeUndefined() + + await wrapper.setProps({ isSubmitting: false }) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.emitted('submit')).toEqual([[{ username: 'alice', password: 'password' }]]) + + wrapper.unmount() + }) +}) diff --git a/spx-gui/src/components/account/sign-in/password-section/PasswordSection.vue b/spx-gui/src/components/account/sign-in/password-section/PasswordSection.vue index 86e8c8779..ab9b1d013 100644 --- a/spx-gui/src/components/account/sign-in/password-section/PasswordSection.vue +++ b/spx-gui/src/components/account/sign-in/password-section/PasswordSection.vue @@ -1,17 +1,19 @@ @@ -80,8 +118,8 @@ async function handleSubmit() { /> - - {{ isSubmitting ? $t({ en: 'Signing in…', zh: '登录中…' }) : $t({ en: 'Sign In', zh: '立即登录' }) }} + + {{ submitText }}