diff --git a/README.md b/README.md index e2cd5a2e..a3a3726a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ UCTale은 이야기의 중요한 상태와 판정을 서버에서 관리하고, | Backend | Java 21, Spring Boot 4.1, Spring Web MVC, Spring Data JPA | | Database | PostgreSQL, Flyway | | Test Database | H2 + Testcontainers PostgreSQL 17.6 | -| Narrative AI | Google Gemini 3.7 Flash | +| Narrative AI | Google Gemini 3.8 Flash | | Image AI | Pollinations | | Deployment | Vercel, Render | @@ -93,7 +93,8 @@ UCTale은 이야기의 중요한 상태와 판정을 서버에서 관리하고, ```env GOOGLE_AI_API_KEY=... -GOOGLE_AI_MODEL=gemini-3.7-flash +GOOGLE_AI_MODEL=gemini-3.8-flash +GOOGLE_AI_FALLBACK_MODEL=gemini-3.5-flash-lite POLLINATIONS_TOKEN=... GAME_ACCESS_PASSWORD=... GAME_ACCESS_SESSION_SECRET=32자 이상의 충분히 긴 임의 문자열 diff --git a/docs/architecture/gemini-narrative-provider.md b/docs/architecture/gemini-narrative-provider.md index e7dfdcc7..f3a701bd 100644 --- a/docs/architecture/gemini-narrative-provider.md +++ b/docs/architecture/gemini-narrative-provider.md @@ -4,7 +4,7 @@ UCTale의 Narrative provider 모델 버전을 game domain이나 `NarrativeGenerator` port에 노출하지 않고 provider adapter 설정으로 관리합니다. -현재 production primary 기본 모델은 `gemini-3.7-flash`입니다. Google 공식 문서에서 stable Flash로 제공되며 structured outputs와 `low` / `medium` / `high` thinking level을 지원합니다. 일시적인 provider 408/429/5xx 또는 network failure가 발생하면 bounded recovery의 다음 실제 provider attempt에서 stable `gemini-3.6-flash` fallback을 사용할 수 있습니다. +현재 production primary 기본 모델은 `gemini-3.8-flash`입니다. Google 공식 문서에서 stable Flash로 제공되며 structured outputs와 `low` / `medium` / `high` thinking level을 지원합니다. 일시적인 provider 408/429/5xx 또는 network failure가 발생하면 bounded recovery의 다음 실제 provider attempt에서 stable `gemini-3.5-flash-lite` fallback을 사용할 수 있습니다. 모델 선택 근거와 당시 가격은 구현 PR에 기록합니다. 이 문서는 현재 runtime 계약을 설명하며 별도 자체 benchmark 결과를 주장하지 않습니다. @@ -12,8 +12,8 @@ UCTale의 Narrative provider 모델 버전을 game domain이나 `NarrativeGenera `application.properties`의 기본값은 다음 환경변수로 override할 수 있습니다. -- `GOOGLE_AI_MODEL`: primary stable Flash model ID. 기본 `gemini-3.7-flash` -- `GOOGLE_AI_FALLBACK_MODEL`: transient provider failure용 stable Flash model ID. 기본 `gemini-3.6-flash` +- `GOOGLE_AI_MODEL`: primary stable Flash model ID. 기본 `gemini-3.8-flash` +- `GOOGLE_AI_FALLBACK_MODEL`: transient provider failure용 stable Flash model ID. 기본 `gemini-3.5-flash-lite` - `GOOGLE_AI_THINKING_OPENING`: opening thinking level. 기본 `medium` - `GOOGLE_AI_THINKING_PROGRESS`: progress thinking level. 기본 `low` - `GOOGLE_AI_API_KEY`: Gemini API key @@ -22,7 +22,7 @@ UCTale의 Narrative provider 모델 버전을 game domain이나 `NarrativeGenera - `GAME_TURN_RESERVATION_LEASE_SECONDS`: progress reservation lease. 기본 180초 - `GAME_TURN_RECOVERY_COOLDOWN_SECONDS`: provider recovery cooldown. 기본 30초 -`latest`, preview, experimental alias는 production 설정으로 허용하지 않습니다. primary와 fallback 모두 `gemini-{major}.{minor}-flash` 형태의 명시적인 stable Flash ID만 허용합니다. +`latest`, preview, experimental alias는 production 설정으로 허용하지 않습니다. primary와 fallback 모두 `gemini-{major}.{minor}-flash` 또는 `gemini-{major}.{minor}-flash-lite` 형식의 명시적인 안정 모델 ID만 허용합니다. ## compatibility boundary @@ -61,7 +61,7 @@ Gemini request는 다음을 유지합니다. - HTTP 408 - HTTP 429 - HTTP 5xx -- connect/read I/O 등 `ResourceAccessException` +- connect/read I/O 등 `ResourceAccessException`과 `RestClientException`에 감싸진 `SocketTimeoutException` 이 retry는 adapter 내부에서 숨겨진 추가 호출을 만들지 않습니다. 첫 호출과 각 retry는 각각 하나의 실제 provider attempt이며 `NarrativeRecoveryExecutor`의 retry count와 일치합니다. progress에서는 기존 `markProviderAttemptStarted` callback이 각 실제 provider attempt 전에 한 번씩 실행됩니다. 따라서 provider 호출이 재시도되어도 canonical game state는 narrative 성공 후 기존 commit 경계에서 한 번만 저장됩니다. @@ -103,6 +103,10 @@ progress의 transient retry가 모두 소진되면 API는 영구 차단 대신 ` bounded recovery와 canonical commit 경계는 기존 정책을 그대로 유지합니다. 모델 설정이나 Narrative 출력 언어 계약은 canonical game state 저장 의미를 변경하지 않습니다. +## 장애 및 배포 확인 + +2026-10-27 운영 로그에서 primary/fallback Gemini HTTP 503과 40초 read timeout을 확인했습니다. 새 버전의 지원 여부를 확인한 뒤 배포해야 하며, 환경변수 override를 적용할 경우 애플리케이션 모델 검증을 통과하는지 점검합니다. 이 문서의 모델 기본값은 코드 배포에 따라 적용되며 운영 환경변수로 별도 override될 수 있습니다. provider가 반복적으로 503을 반환할 경우 모델 교체만으로 성공을 보장하지 않습니다. + ## rollback production에서 문제가 발생하면 코드 변경 없이 `GOOGLE_AI_MODEL`, `GOOGLE_AI_FALLBACK_MODEL`과 필요한 thinking 설정을 검증된 stable Flash로 변경해 rollback할 수 있습니다. @@ -120,7 +124,7 @@ rollback 후에는 최소한 다음을 확인합니다. ## 공식 참고 - Google Gemini models: https://ai.google.dev/gemini-api/docs/models -- Gemini 3.7 Flash: https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash -- Gemini 3.6 Flash: https://ai.google.dev/gemini-api/docs/models/gemini-3.6-flash +- Gemini 3.8 Flash: https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash +- Gemini 3.5 Flash-Lite: https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite - Thinking: https://ai.google.dev/gemini-api/docs/generate-content/thinking - Pricing: https://ai.google.dev/gemini-api/docs/pricing diff --git a/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java b/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java index 89ac4bfd..1d46fe9d 100644 --- a/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java +++ b/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java @@ -14,8 +14,10 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientResponseException; +import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -303,6 +305,15 @@ private NarrativeTurn generate( logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "PROVIDER_RETRYABLE_FAILURE"); log.warn("gemini_provider_network_failure model={} context={} error={}", modelId, errorContext, exception.getClass().getSimpleName()); throw recoverable("PROVIDER_NETWORK", "Gemini provider 네트워크 호출이 일시적으로 실패했습니다.", exception); + } catch (RestClientException exception) { + if (hasReadTimeoutCause(exception)) { + logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "PROVIDER_RETRYABLE_FAILURE"); + log.warn("gemini_provider_read_timeout model={} context={}", modelId, errorContext); + throw recoverable("PROVIDER_NETWORK", "Gemini provider 응답 대기 시간이 초과되었습니다.", exception); + } + logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "PROVIDER_FAILURE"); + log.error("Gemini provider 처리 실패 model={} context={} error={}", modelId, errorContext, exception.getClass().getSimpleName()); + throw new NarrativeProviderException("Gemini provider 응답 처리에 실패했습니다.", exception); } catch (JacksonException exception) { logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "REQUEST_SERIALIZATION_FAILURE"); throw new IllegalStateException("Gemini 요청 직렬화에 실패했습니다.", exception); @@ -313,6 +324,15 @@ private NarrativeTurn generate( } } + private boolean hasReadTimeoutCause(Throwable exception) { + for (Throwable cause = exception; cause != null; cause = cause.getCause()) { + if (cause instanceof SocketTimeoutException) { + return true; + } + } + return false; + } + String createRequestBody( String userPrompt, GeminiProviderSettings.ThinkingLevel thinkingLevel diff --git a/src/main/java/com/uctale/uctale/provider/gemini/GeminiProviderSettings.java b/src/main/java/com/uctale/uctale/provider/gemini/GeminiProviderSettings.java index ebc3ea30..0eafcfe8 100644 --- a/src/main/java/com/uctale/uctale/provider/gemini/GeminiProviderSettings.java +++ b/src/main/java/com/uctale/uctale/provider/gemini/GeminiProviderSettings.java @@ -12,7 +12,7 @@ @Component public final class GeminiProviderSettings { - private static final Pattern STABLE_FLASH_MODEL = Pattern.compile("^gemini-(\\d+)\\.(\\d+)-flash$"); + private static final Pattern STABLE_FLASH_MODEL = Pattern.compile("^gemini-(\\d+)\\.(\\d+)-flash(?:-lite)?$"); private static final String GENERATE_CONTENT_URL = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"; @@ -28,7 +28,7 @@ public final class GeminiProviderSettings { public GeminiProviderSettings( @Value("${google.ai.api-key}") String apiKey, @Value("${google.ai.model}") String modelId, - @Value("${google.ai.fallback-model:gemini-3.6-flash}") String fallbackModelId, + @Value("${google.ai.fallback-model:gemini-3.5-flash-lite}") String fallbackModelId, @Value("${google.ai.thinking.opening}") String openingThinkingLevel, @Value("${google.ai.thinking.progress}") String progressThinkingLevel ) { @@ -47,7 +47,7 @@ public GeminiProviderSettings( String openingThinkingLevel, String progressThinkingLevel ) { - this(apiKey, modelId, "gemini-3.6-flash", openingThinkingLevel, progressThinkingLevel); + this(apiKey, modelId, "gemini-3.5-flash-lite", openingThinkingLevel, progressThinkingLevel); } String apiKey() { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 92a60385..3889d9ce 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,8 @@ spring.application.name=uctale google.ai.api-key=${GOOGLE_AI_API_KEY} -google.ai.model=${GOOGLE_AI_MODEL:gemini-3.7-flash} -google.ai.fallback-model=${GOOGLE_AI_FALLBACK_MODEL:gemini-3.6-flash} +google.ai.model=${GOOGLE_AI_MODEL:gemini-3.8-flash} +google.ai.fallback-model=${GOOGLE_AI_FALLBACK_MODEL:gemini-3.5-flash-lite} google.ai.thinking.opening=${GOOGLE_AI_THINKING_OPENING:medium} google.ai.thinking.progress=${GOOGLE_AI_THINKING_PROGRESS:low} google.ai.connect-timeout-ms=${GOOGLE_AI_CONNECT_TIMEOUT_MS:10000} diff --git a/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java b/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java index 2f607315..4fbda019 100644 --- a/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java +++ b/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java @@ -12,6 +12,8 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import java.net.SocketTimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,7 +27,7 @@ class GeminiNarrativeAdapterTest { - private static final String TEST_MODEL = "gemini-3.7-flash"; + private static final String TEST_MODEL = "gemini-3.8-flash"; private GeminiNarrativeAdapter adapter; private GeminiProviderSettings settings; @@ -165,6 +167,29 @@ void repairOpening_UsesSafeReasonCodeAndOpeningThinkingLevel() { mockServer.verify(); } + @Test + @DisplayName("RestClientException 안의 읽기 타임아웃을 recoverable 네트워크 실패로 분류한다") + void openingWrappedReadTimeoutIsRecoverable() { + mockServer.expect(requestTo(settings.generateContentUrl())) + .andRespond(request -> { throw new RestClientException("response failure", new SocketTimeoutException("Read timed out")); }); + + assertThatThrownBy(() -> adapter.createOpening("세계", "캐릭터")) + .isInstanceOfSatisfying(RecoverableNarrativeResponseException.class, exception -> + assertThat(exception.reasonCode()).isEqualTo("PROVIDER_NETWORK")); + mockServer.verify(); + } + + @Test + @DisplayName("일반 RestClientException은 retryable로 처리하지 않는다") + void openingUnrelatedRestClientExceptionIsNotRetryable() { + mockServer.expect(requestTo(settings.generateContentUrl())) + .andRespond(request -> { throw new RestClientException("conversion failure"); }); + + assertThatThrownBy(() -> adapter.createOpening("세계", "캐릭터")) + .isInstanceOf(NarrativeProviderException.class); + mockServer.verify(); + } + private String validNarrativeJson() { return "{\"title\":\"첫날 밤\",\"story_text\":\"좀비가 나타났다!\",\"choices\":[{\"id\":1,\"text\":\"도망간다\"}],\"visual_assets\":{\"background\":\"dark subway\",\"characters\":[],\"assets\":[]}}"; } diff --git a/src/test/java/com/uctale/uctale/provider/gemini/GeminiProviderSettingsTest.java b/src/test/java/com/uctale/uctale/provider/gemini/GeminiProviderSettingsTest.java index c26bb19d..45159074 100644 --- a/src/test/java/com/uctale/uctale/provider/gemini/GeminiProviderSettingsTest.java +++ b/src/test/java/com/uctale/uctale/provider/gemini/GeminiProviderSettingsTest.java @@ -12,11 +12,11 @@ class GeminiProviderSettingsTest { @DisplayName("Gemini 3 stable Flash는 model URL과 thinkingLevel 계약을 설정에서 만든다") void gemini3Stable_UsesThinkingLevel() { GeminiProviderSettings settings = new GeminiProviderSettings( - "key", "gemini-3.7-flash", "medium", "low" + "key", "gemini-3.8-flash", "medium", "low" ); - assertThat(settings.modelId()).isEqualTo("gemini-3.7-flash"); - assertThat(settings.generateContentUrl()).endsWith("/models/gemini-3.7-flash:generateContent"); + assertThat(settings.modelId()).isEqualTo("gemini-3.8-flash"); + assertThat(settings.generateContentUrl()).endsWith("/models/gemini-3.8-flash:generateContent"); assertThat(settings.thinkingConfig(settings.openingThinkingLevel())) .containsEntry("thinkingLevel", "medium") .doesNotContainKey("thinkingBudget"); @@ -28,14 +28,14 @@ void gemini3Stable_UsesThinkingLevel() { @DisplayName("provider transient retry는 설정된 stable fallback 모델을 선택한다") void providerTransientRetry_UsesStableFallbackModel() { GeminiProviderSettings settings = new GeminiProviderSettings( - "key", "gemini-3.7-flash", "gemini-3.6-flash", "medium", "low" + "key", "gemini-3.8-flash", "gemini-3.5-flash-lite", "medium", "low" ); - assertThat(settings.retryModelId("PROVIDER_HTTP_503")).isEqualTo("gemini-3.6-flash"); - assertThat(settings.retryModelId("PROVIDER_RATE_LIMIT")).isEqualTo("gemini-3.6-flash"); - assertThat(settings.retryModelId("MALFORMED_JSON")).isEqualTo("gemini-3.7-flash"); + assertThat(settings.retryModelId("PROVIDER_HTTP_503")).isEqualTo("gemini-3.5-flash-lite"); + assertThat(settings.retryModelId("PROVIDER_RATE_LIMIT")).isEqualTo("gemini-3.5-flash-lite"); + assertThat(settings.retryModelId("MALFORMED_JSON")).isEqualTo("gemini-3.8-flash"); assertThat(settings.generateContentUrl(settings.fallbackModelId())) - .endsWith("/models/gemini-3.6-flash:generateContent"); + .endsWith("/models/gemini-3.5-flash-lite:generateContent"); } @Test @@ -66,7 +66,7 @@ void unstableAliases_AreRejected() { assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-flash-latest", "medium", "low")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("stable Flash model ID"); - assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.7-flash-preview", "medium", "low")) + assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.8-flash-preview", "medium", "low")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("stable Flash model ID"); } @@ -74,7 +74,7 @@ void unstableAliases_AreRejected() { @Test @DisplayName("지원하지 않는 thinking level은 시작 시 fail-fast 한다") void unsupportedThinkingLevel_IsRejected() { - assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.7-flash", "minimal", "low")) + assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.8-flash", "minimal", "low")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("allowed: low, medium, high"); }