fix(flutter): 토큰 갱신 429 로 로그아웃되지 않게, 갱신 뒤 사진 다시 보내기

리뷰에서 나온 앱 쪽 문제를 고친다.
- 토큰 갱신이 429 를 받으면 세션을 끊던 문제 — 거절(401·400)만 세션을 끊고, 429 는 REQ_001·그 밖은 SYS_001 로 알린다
  (서버는 갱신을 IP 당 분당 10회로 막아 통신사 망처럼 여럿이 한 IP 를 쓰면 실제로 생긴다)
- 액세스 토큰이 만료된 뒤 사진을 올리면 다시 보낼 때 FormData 가 이미 쓰여 실패하던 문제 — 복사본으로 다시 보낸다
- 에러 사전 계약 테스트에 앱이 직접 쓰는 도메인 코드(FILE·DIARY·CARD·SCHED)를 더한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:57:34 +09:00
parent 3f84d7b747
commit 043e14a3fa
3 changed files with 60 additions and 7 deletions
+17 -7
View File
@@ -24,7 +24,8 @@ const String _kRetriedExtra = 'authRetried';
const String _kRefreshUnreachableExtra = 'refreshUnreachable';
/// 토큰 갱신 결과
enum _RefreshResult { refreshed, rejected, unreachable }
/// 토큰 갱신 결과 — 거절(401·400)만 세션을 끊는다. 서버에 닿지 못했거나 너무 잦아 막혔으면(429) 세션은 그대로 둔다
enum _RefreshResult { refreshed, rejected, unreachable, throttled }
/// 토큰 갱신 경로 (backend AuthController `POST /auth/tokens`)
const String kRefreshPath = '/auth/tokens';
@@ -134,12 +135,17 @@ class DioClient {
if (sent != 'Bearer ${tokens.accessToken}') return true;
final result = await _refreshOnce();
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = true;
// 갱신하지 못했지만 다시 로그인할 일은 아닐 때 — 이번 요청이 어떤 코드로 실패할지 남겨 둔다
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = 'SYS_001';
if (result == _RefreshResult.throttled) options.extra[_kRefreshUnreachableExtra] = 'REQ_001';
return result == _RefreshResult.refreshed;
}
RequestOptions _withFreshToken(RequestOptions options) {
options.extra[_kRetriedExtra] = true;
// 사진 올리기(FormData)는 한 번 보내면 다시 쓸 수 없다 — 다시 보낼 때는 복사본으로
final data = options.data;
if (data is FormData) options.data = data.clone();
final token = _session.tokens?.accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
return options;
@@ -162,9 +168,12 @@ class DioClient {
await _session.save(AuthTokens.fromJson(data));
return _RefreshResult.refreshed;
} on DioException catch (e) {
final response = e.response;
if (response == null || (response.statusCode ?? 0) >= 500) return _RefreshResult.unreachable;
return _RefreshResult.rejected;
final status = e.response?.statusCode;
// 서버가 리프레시 토큰을 받아들이지 않았을 때(401·형식 오류 400)만 세션을 끊는다.
// 429(한 IP 를 여럿이 쓰는 통신사 망에서도 생긴다)·5xx·응답 없음은 잠깐의 문제라 세션을 둔다
if (status == 401 || status == 400) return _RefreshResult.rejected;
if (status == 429) return _RefreshResult.throttled;
return _RefreshResult.unreachable;
}
}
@@ -194,8 +203,9 @@ class DioClient {
// 갱신하려다 서버에 닿지 못했으면 다시 로그인할 일이 아니므로 일시적 오류(SYS_001)로 둔다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && e.requestOptions.headers['Authorization'] != null) {
if (e.requestOptions.extra[_kRefreshUnreachableExtra] == true) {
code = 'SYS_001';
final kept = e.requestOptions.extra[_kRefreshUnreachableExtra];
if (kept is String) {
code = kept;
} else {
if (_session.hasSession) await _session.clear(expired: true);
code = 'AUTH_002';