fix(mobile-templates): discard the generation idempotency key once the server gives a final answer

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 65320288d5
commit 7a8e1e3e25
5 changed files with 393 additions and 19 deletions

View file

@ -48,3 +48,17 @@ export async function clearEveryGenerationIdempotencyKey(): Promise<void> {
const keys = (await AsyncStorage.getAllKeys()).filter((key) => key.startsWith(PREFIX))
if (keys.length > 0) await AsyncStorage.multiRemove(keys)
}
/**
* Port for the per (user, meeting, template) pending-generation key. The
* generation use case depends on this interface, not on AsyncStorage.
*/
export interface GenerationKeyStore {
getOrCreate: (userId: string, meetingId: string, templateId: string) => Promise<string>
clear: (userId: string, meetingId: string, templateId: string) => Promise<void>
}
export const asyncStorageGenerationKeyStore: GenerationKeyStore = {
getOrCreate: getOrCreateGenerationIdempotencyKey,
clear: clearGenerationIdempotencyKey,
}

View file

@ -0,0 +1,55 @@
/**
* Idempotency-key lifecycle policy for meeting document generation.
*
* The server binds an idempotency key to exactly one generation request row.
* Once that row reaches a final state ('succeeded' or 'failed'), or the server
* refuses the key outright (hash conflict, quota, permission), reusing the key
* can only ever replay that final answer. The key must therefore be discarded
* so the next user attempt starts a fresh operation.
*
* The key is retained only while the server's state for it is unknown or still
* running: no response reached the client (network loss, abort, local
* pre-flight failure), the server reports the request is still processing, or
* the response does not come from the generation function itself (an unknown
* 5xx such as a gateway timeout, while the worker may still commit). Retrying
* with the same key then converges on the server's real outcome instead of
* spending a second generation.
*
* Pure and IO-free so the full status/error-code matrix can be table-tested.
*/
export type GenerationAttemptOutcome =
| { kind: 'succeeded' }
| { kind: 'server-response'; status: number; errorCode: string | null }
| { kind: 'no-response' }
export type GenerationKeyDisposition = 'discard' | 'retain'
/** 5xx codes the generation function emits only after marking the row failed. */
const TERMINAL_SERVER_FAILURE_CODES: ReadonlySet<string> = new Set([
'provider_timeout',
'provider_request_failed',
'provider_invalid_response',
'commit_failed',
])
/** 4xx statuses where the server has refused or finalized this key. */
const TERMINAL_CLIENT_ERROR_STATUSES: ReadonlySet<number> = new Set([400, 403, 404, 429])
export function generationKeyDisposition(
outcome: GenerationAttemptOutcome,
): GenerationKeyDisposition {
if (outcome.kind === 'succeeded') return 'discard'
if (outcome.kind === 'no-response') return 'retain'
const { status, errorCode } = outcome
if (status >= 200 && status < 300) return 'discard'
if (status === 409) return errorCode === 'generation_failed' ? 'discard' : 'retain'
if (TERMINAL_CLIENT_ERROR_STATUSES.has(status)) return 'discard'
if (status >= 500 && errorCode !== null && TERMINAL_SERVER_FAILURE_CODES.has(errorCode)) {
return 'discard'
}
// 401 (session expired before the claim ran), 500 internal_error and any
// gateway-produced 5xx leave the key's server state unknown.
return 'retain'
}

View file

@ -9,6 +9,14 @@ import type {
} from '@d3ro/api-client'
import { SUPABASE_URL } from '@d3ro/core/supabase-config'
import { supabase } from '../../lib/supabase'
import {
asyncStorageGenerationKeyStore,
type GenerationKeyStore,
} from './generation-idempotency'
import {
generationKeyDisposition,
type GenerationAttemptOutcome,
} from './generation-key-policy'
import type {
GenerateMeetingDocumentOptions,
GeneratedMeetingDocument,
@ -29,11 +37,18 @@ export type TemplateServiceErrorCode =
| 'server'
| 'validation'
/** The HTTP answer the server gave, when an error came from a server response. */
export interface TemplateServiceResponseInfo {
status: number
errorCode: string | null
}
export class TemplateServiceError extends Error {
constructor(
public readonly code: TemplateServiceErrorCode,
message: string,
public readonly retryable = false,
public readonly response: TemplateServiceResponseInfo | null = null,
) {
super(message)
this.name = 'TemplateServiceError'
@ -357,14 +372,21 @@ export function renderDictationTemplate(
function generationError(status: number, body: unknown): TemplateServiceError {
const code = isRecord(body) && typeof body.error === 'string' ? body.error : ''
if (status === 401) return new TemplateServiceError('auth', code)
if (status === 403) return new TemplateServiceError('forbidden', code)
if (status === 404) return new TemplateServiceError('not-found', code)
if (status === 409) return new TemplateServiceError('in-progress', code, true)
if (status === 429) return new TemplateServiceError('quota', code, true)
if ([502, 503, 504].includes(status)) return new TemplateServiceError('provider', code, true)
if (status === 400) return new TemplateServiceError('validation', code)
return new TemplateServiceError('server', code || 'Document generation failed', true)
const response: TemplateServiceResponseInfo = { status, errorCode: code || null }
if (status === 401) return new TemplateServiceError('auth', code, false, response)
if (status === 403) return new TemplateServiceError('forbidden', code, false, response)
if (status === 404) return new TemplateServiceError('not-found', code, false, response)
if (status === 409 && code === 'generation_failed') {
// The previous attempt with this key failed for good; a new attempt can succeed.
return new TemplateServiceError('server', code, true, response)
}
if (status === 409) return new TemplateServiceError('in-progress', code, true, response)
if (status === 429) return new TemplateServiceError('quota', code, true, response)
if ([502, 503, 504].includes(status)) {
return new TemplateServiceError('provider', code, true, response)
}
if (status === 400) return new TemplateServiceError('validation', code, false, response)
return new TemplateServiceError('server', code || 'Document generation failed', true, response)
}
function isRecord(value: unknown): value is Record<string, unknown> {
@ -416,6 +438,71 @@ export async function generateMeetingDocument(
}
}
export interface GenerateMeetingDocumentOnceOptions
extends Omit<GenerateMeetingDocumentOptions, 'idempotencyKey'> {
userId: string
}
export interface GenerateMeetingDocumentOnceDeps {
keyStore: GenerationKeyStore
generate: (options: GenerateMeetingDocumentOptions) => Promise<GeneratedMeetingDocument>
}
const DEFAULT_GENERATE_ONCE_DEPS: GenerateMeetingDocumentOnceDeps = {
keyStore: asyncStorageGenerationKeyStore,
generate: generateMeetingDocument,
}
/** Maps a failed attempt to what the client actually learned from the server. */
export function generationAttemptOutcome(error: unknown): GenerationAttemptOutcome {
if (error instanceof TemplateServiceError && error.response !== null) {
return {
kind: 'server-response',
status: error.response.status,
errorCode: error.response.errorCode,
}
}
return { kind: 'no-response' }
}
/**
* Generates one meeting document and owns its idempotency key's lifecycle:
* the key for (user, meeting, template) is reused across ambiguous retries and
* discarded once the server has given a final answer for it, success or not.
*/
export async function generateMeetingDocumentOnce(
options: GenerateMeetingDocumentOnceOptions,
deps: GenerateMeetingDocumentOnceDeps = DEFAULT_GENERATE_ONCE_DEPS,
): Promise<GeneratedMeetingDocument> {
const { userId, ...request } = options
let idempotencyKey: string
try {
idempotencyKey = await deps.keyStore.getOrCreate(userId, request.meetingId, request.templateId)
} catch (error) {
throw toServiceError(error)
}
let outcome: GenerationAttemptOutcome
let generated: GeneratedMeetingDocument | null = null
let failure: unknown = null
try {
generated = await deps.generate({ ...request, idempotencyKey })
outcome = { kind: 'succeeded' }
} catch (error) {
failure = error
outcome = generationAttemptOutcome(error)
}
if (generationKeyDisposition(outcome) === 'discard') {
// A failed cleanup only costs one extra replay of the final answer.
await deps.keyStore
.clear(userId, request.meetingId, request.templateId)
.catch(() => undefined)
}
if (generated === null) throw toServiceError(failure)
return generated
}
export function subscribeToTemplates(
userId: string,
onRemoteChange: () => void,

View file

@ -29,9 +29,7 @@ import {
type PreparedPortableFile,
} from '../features/data-portability'
import {
clearGenerationIdempotencyKey,
generateMeetingDocument,
getOrCreateGenerationIdempotencyKey,
generateMeetingDocumentOnce,
loadTemplateLibrary,
TemplateServiceError,
} from '../features/templates'
@ -417,18 +415,13 @@ export default function MeetingDetailScreen({
setBusyKey('document-generate')
setError(null)
try {
const idempotencyKey = await getOrCreateGenerationIdempotencyKey(
user.id,
meetingId,
templateId,
)
const title = `${detail.meeting.title?.trim() || t('mobile.meetings.untitled')} · ${selectedDocumentTemplate.name}`
.slice(0, 160)
const generated = await generateMeetingDocument({
const generated = await generateMeetingDocumentOnce({
userId: user.id,
accessToken: session.access_token,
meetingId,
templateId,
idempotencyKey,
title,
})
setDetail((current) => {
@ -444,7 +437,6 @@ export default function MeetingDetailScreen({
}
})
setExpandedDocumentId(generated.document.id)
await clearGenerationIdempotencyKey(user.id, meetingId, templateId).catch(() => undefined)
} catch (requestError) {
setError(templateErrorMessage(requestError, t))
} finally {