Просмотр исходного кода

fix: 自动重试临时网关错误

彭峰 1 месяц назад
Родитель
Сommit
106771d8a8
2 измененных файлов с 54 добавлено и 24 удалено
  1. 20 1
      src/app/core/api.service.spec.ts
  2. 34 23
      src/app/core/api.service.ts

+ 20 - 1
src/app/core/api.service.spec.ts

@@ -1,6 +1,6 @@
 import { provideHttpClient } from '@angular/common/http';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
-import { TestBed } from '@angular/core/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 import { API_CONFIG } from './app.constants';
 import { ApiError, ApiService } from './api.service';
 import { SessionService } from './session.service';
@@ -38,6 +38,25 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: [] });
   });
 
+  it('retries transient gateway failures before surfacing a friendly error', fakeAsync(() => {
+    let actual: unknown;
+    api.get('order_list', { cpage: 1 }).subscribe({ error: (error) => actual = error });
+
+    http.expectOne((candidate) => candidate.urlWithParams.includes('action=order_list'))
+      .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
+    tick(400);
+
+    http.expectOne((candidate) => candidate.urlWithParams.includes('action=order_list'))
+      .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
+    tick(800);
+
+    http.expectOne((candidate) => candidate.urlWithParams.includes('action=order_list'))
+      .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
+
+    expect(actual instanceof ApiError).toBeTrue();
+    expect((actual as ApiError).message).toBe('服务暂时繁忙,已自动重试但仍未成功,请稍后再试。');
+  }));
+
   it('nests reserved id parameters inside params', () => {
     api.get('node_get', { id: 81 }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);

+ 34 - 23
src/app/core/api.service.ts

@@ -1,6 +1,6 @@
 import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
-import { catchError, map, Observable, throwError } from 'rxjs';
+import { catchError, map, Observable, retry, throwError, timer } from 'rxjs';
 import { API_CONFIG } from './app.constants';
 import { requiresCloudSession, usesCloudGateway } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
@@ -21,9 +21,7 @@ export class ApiService {
     const hasAnySession = Boolean(this.sessions.token());
     const token = this.parseSessionToken();
     if (usesCloudGateway(action, params, Boolean(token), hasAnySession)) return this.callCloud<T>(action, params, requiresCloudSession(action, params) ? token : '');
-    return this.http
-      .get<unknown>(this.actionUrl(action), { params: this.toHttpParams(params) })
-      .pipe(map((response) => this.normalize<T>(response)), catchError((error) => this.handleError(error)));
+    return this.resilient<T>(this.http.get<unknown>(this.actionUrl(action), { params: this.toHttpParams(params) }));
   }
 
   post<T>(action: string, body: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
@@ -42,11 +40,9 @@ export class ApiService {
 
   private postLegacy<T>(action: string, body: Record<string, unknown>): Observable<ApiEnvelope<T>> {
     const encoded = this.toHttpParams(body);
-    return this.http
-      .post<unknown>(this.actionUrl(action), encoded.toString(), {
-        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-      })
-      .pipe(map((response) => this.normalize<T>(response)), catchError((error) => this.handleError(error)));
+    return this.resilient<T>(this.http.post<unknown>(this.actionUrl(action), encoded.toString(), {
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    }));
   }
 
   callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = ''): Observable<ApiEnvelope<T>> {
@@ -54,22 +50,18 @@ export class ApiService {
       ...(sessionToken ? { token: sessionToken } : {}),
       params: { action, ...params },
     };
-    return this.http
-      .post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
-        headers: {
-          'Content-Type': 'application/json',
-          'X-Parse-Application-Id': API_CONFIG.parseAppId,
-        },
-      })
-      .pipe(map((response) => this.normalize<T>(response)), catchError((error) => this.handleError(error)));
+    return this.resilient<T>(this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Parse-Application-Id': API_CONFIG.parseAppId,
+      },
+    }));
   }
 
   upload(file: File): Observable<ApiEnvelope<string>> {
     const formData = new FormData();
     formData.append('file', file, file.name);
-    return this.http
-      .post<unknown>(this.actionUrl('upload'), formData)
-      .pipe(map((response) => this.normalize<string>(response)), catchError((error) => this.handleError(error)));
+    return this.resilient<string>(this.http.post<unknown>(this.actionUrl('upload'), formData));
   }
 
   actionUrl(action: string): string {
@@ -105,6 +97,19 @@ export class ApiService {
     return normalized;
   }
 
+  private resilient<T>(request: Observable<unknown>): Observable<ApiEnvelope<T>> {
+    return request.pipe(
+      retry({
+        count: 2,
+        delay: (error, retryCount) => this.isTransientError(error)
+          ? timer(retryCount * 400)
+          : throwError(() => error),
+      }),
+      map((response) => this.normalize<T>(response)),
+      catchError((error) => this.handleError(error)),
+    );
+  }
+
   private toHttpParams(values: Record<string, unknown>): HttpParams {
     let params = new HttpParams();
     for (const [key, raw] of Object.entries(values)) {
@@ -119,9 +124,11 @@ export class ApiService {
     if (error instanceof ApiError) return throwError(() => error);
     if (error instanceof HttpErrorResponse) {
       const payload = error.error as { retmsg?: string } | string | null;
-      const message = typeof payload === 'object' && payload?.retmsg
-        ? payload.retmsg
-        : error.message || '网络请求失败';
+      const message = this.isTransientError(error)
+        ? '服务暂时繁忙,已自动重试但仍未成功,请稍后再试。'
+        : typeof payload === 'object' && payload?.retmsg
+          ? payload.retmsg
+          : error.message || '网络请求失败';
       return throwError(() => new ApiError(message, error.status, payload));
     }
     return throwError(() => new ApiError(error instanceof Error ? error.message : '未知错误'));
@@ -131,4 +138,8 @@ export class ApiService {
     const token = this.sessions.token();
     return token.startsWith('r:') ? token : '';
   }
+
+  private isTransientError(error: unknown): boolean {
+    return error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
+  }
 }