Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,8 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"429":
$ref: "#/components/responses/TooManyRequests"
get:
operationId: getAccountSession
tags:
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions spx-gui/src/apis/account/index.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
8 changes: 7 additions & 1 deletion spx-gui/src/apis/account/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<CurrentAccountSession> {
return (await accountClient
.post('/session', {
Expand Down
22 changes: 22 additions & 0 deletions spx-gui/src/apis/common/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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'))

Expand Down
12 changes: 11 additions & 1 deletion spx-gui/src/components/account/sign-in/SignInForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
createSessionWithPassword,
deleteSession,
getIdentityProviders,
getPasswordSignInRetryAfter,
getSession
} from '@/apis/account'
import type { IdentityProvider, OAuthRequest, PasswordSignInPayload } from '@/apis/account'
Expand Down Expand Up @@ -89,6 +90,7 @@ const { fn: handleSwitchAccount, isLoading: isSwitchingAccount } = useMessageHan
)

const isRedirectingToProvider = ref(false)
const passwordSignInRetryAfter = ref<number | null>(null)

function handleSignInWithProvider(provider: IdentityProvider) {
markPendingAuthorization(props.request)
Expand All @@ -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: '登录失败' }
Expand Down Expand Up @@ -167,6 +176,7 @@ const { fn: handleSignInWithPasswordSubmit, isLoading: isSubmittingSignInWithPas
<PasswordSection
v-else
:is-submitting="isSubmittingSignInWithPassword"
:retry-after="passwordSignInRetryAfter"
@submit="handleSignInWithPasswordSubmit"
/>
</template>
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'

import type { PasswordSignInPayload } from '@/apis/account'
import { UIForm, UIFormItem, UIFullWidthButton, UIIconTextInput, useForm } from '@/components/ui'
import { useI18n } from '@/utils/i18n'
import { useInterval } from '@/utils/utils'

import eyeIconUrl from './eye.svg'
import eyeOffIconUrl from './eye-off.svg'
import lockIconUrl from './lock.svg'
import userIconUrl from './user.svg'

defineProps<{
const props = defineProps<{
isSubmitting: boolean
retryAfter: number | null
}>()

const emit = defineEmits<{
Expand All @@ -20,6 +22,41 @@ const emit = defineEmits<{

const { t } = useI18n()
const showPassword = ref(false)
const now = ref(Date.now())

function updateNow() {
now.value = Date.now()
}

watch(() => props.retryAfter, updateNow)

const retryAfterSeconds = computed(() => {
if (props.retryAfter == null) return 0
return Math.max(0, Math.ceil((props.retryAfter - now.value) / 1000))
})

useInterval(
updateNow,
computed(() => (retryAfterSeconds.value > 0 ? 1000 : null))
)

const retryAfterCountdown = computed(() => {
const minutes = Math.floor(retryAfterSeconds.value / 60)
const seconds = retryAfterSeconds.value % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})

const isSubmitDisabled = computed(() => props.isSubmitting || retryAfterSeconds.value > 0)
const submitText = computed(() => {
if (retryAfterSeconds.value > 0) {
return t({
en: `Try again in ${retryAfterCountdown.value}`,
zh: `${retryAfterCountdown.value} 后重试`
})
}
if (props.isSubmitting) return t({ en: 'Signing in…', zh: '登录中…' })
return t({ en: 'Sign In', zh: '立即登录' })
})

const form = useForm({
username: ['', validateUsername],
Expand Down Expand Up @@ -54,7 +91,8 @@ function toggleShowPassword() {
showPassword.value = !showPassword.value
}

async function handleSubmit() {
function handleSubmit() {
if (isSubmitDisabled.value) return
emit('submit', { username: form.value.username.trim(), password: form.value.password })
}
Comment thread
aofei marked this conversation as resolved.
</script>
Expand All @@ -80,8 +118,8 @@ async function handleSubmit() {
/>
</UIFormItem>

<UIFullWidthButton primary html-type="submit" class="mt-6" :disabled="isSubmitting">
{{ isSubmitting ? $t({ en: 'Signing in…', zh: '登录中…' }) : $t({ en: 'Sign In', zh: '立即登录' }) }}
<UIFullWidthButton primary html-type="submit" class="mt-6" :disabled="isSubmitDisabled">
{{ submitText }}
</UIFullWidthButton>
</UIForm>
</template>