Fix duplicate refresh requests

This commit is contained in:
itskovacs 2025-08-06 21:04:02 +02:00
parent 0f85f951ab
commit 550b7ac328
2 changed files with 81 additions and 71 deletions

View File

@ -1,7 +1,7 @@
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { Observable, of } from "rxjs"; import { Observable, of, ReplaySubject } from "rxjs";
import { tap } from "rxjs/operators"; import { tap } from "rxjs/operators";
import { ApiService } from "./api.service"; import { ApiService } from "./api.service";
import { UtilsService } from "./utils.service"; import { UtilsService } from "./utils.service";
@ -22,7 +22,8 @@ const JWT_USER = "TRIP_USER";
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: "root" })
export class AuthService { export class AuthService {
public apiBaseUrl: string; public readonly apiBaseUrl: string;
private refreshInProgressLock$: ReplaySubject<Token> | null = null;
constructor( constructor(
private httpClient: HttpClient, private httpClient: HttpClient,
@ -73,15 +74,32 @@ export class AuthService {
} }
refreshAccessToken(): Observable<Token> { refreshAccessToken(): Observable<Token> {
return this.httpClient if (this.refreshInProgressLock$) {
return this.refreshInProgressLock$.asObservable();
}
this.refreshInProgressLock$ = new ReplaySubject(1);
this.httpClient
.post<Token>(this.apiBaseUrl + "/auth/refresh", { .post<Token>(this.apiBaseUrl + "/auth/refresh", {
refresh_token: this.refreshToken, refresh_token: this.refreshToken,
}) })
.pipe( .pipe(
tap((tokens: Token) => { tap((tokens: Token) => {
this.accessToken = tokens.access_token; this.accessToken = tokens.access_token;
this.refreshInProgressLock$?.next(tokens);
this.refreshInProgressLock$?.complete();
this.refreshInProgressLock$ = null;
}), }),
); )
.subscribe({
error: (err) => {
this.refreshInProgressLock$?.error(err);
this.refreshInProgressLock$ = null;
},
});
return this.refreshInProgressLock$.asObservable();
} }
login(authForm: { username: string; password: string }): Observable<Token> { login(authForm: { username: string; password: string }): Observable<Token> {

View File

@ -1,13 +1,45 @@
import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpRequest } from "@angular/common/http"; import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpRequest,
} from "@angular/common/http";
import { inject } from "@angular/core"; import { inject } from "@angular/core";
import { catchError, Observable, switchMap, throwError } from "rxjs"; import { catchError, Observable, switchMap, take, throwError } from "rxjs";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { UtilsService } from "./utils.service"; import { UtilsService } from "./utils.service";
export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> => { const ERROR_CONFIG: Record<number, { title: string; detail: string }> = {
400: {
title: "Bad Request",
detail: "Unknown error, check console for details",
},
403: { title: "Forbidden", detail: "You are not allowed to do this" },
409: { title: "Conflict", detail: "Conflict on resource" },
413: { title: "Request Entity Too Large", detail: "The resource is too big" },
422: {
title: "Unprocessable Entity",
detail: "The resource you sent was unprocessable",
},
502: {
title: "Bad Gateway",
detail: "Check your connectivity and ensure the server is up",
},
503: { title: "Service Unavailable", detail: "Resource not available" },
};
export const Interceptor = (
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> => {
const authService = inject(AuthService); const authService = inject(AuthService);
const utilsService = inject(UtilsService); const utilsService = inject(UtilsService);
function showAndThrowError(title: string, details: string) {
utilsService.toast("error", title, details);
return throwError(() => details);
}
if (!req.headers.has("enctype") && !req.headers.has("Content-Type")) { if (!req.headers.has("enctype") && !req.headers.has("Content-Type")) {
req = req.clone({ req = req.clone({
setHeaders: { setHeaders: {
@ -17,7 +49,10 @@ export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Obs
}); });
} }
if (authService.accessToken && !authService.isTokenExpired(authService.accessToken)) { if (
authService.accessToken &&
!authService.isTokenExpired(authService.accessToken)
) {
if (req.url.startsWith(authService.apiBaseUrl)) { if (req.url.startsWith(authService.apiBaseUrl)) {
req = req.clone({ req = req.clone({
setHeaders: { Authorization: `Bearer ${authService.accessToken}` }, setHeaders: { Authorization: `Bearer ${authService.accessToken}` },
@ -27,60 +62,13 @@ export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Obs
return next(req).pipe( return next(req).pipe(
catchError((err: HttpErrorResponse) => { catchError((err: HttpErrorResponse) => {
if (err.status == 400) { const errDetails = ERROR_CONFIG[err.status];
if (errDetails) {
console.error(err); console.error(err);
utilsService.toast( return showAndThrowError(
"error", errDetails.title,
"Bad Request", `${err.error?.detail || err.error || errDetails.detail}`,
`${err.error?.detail || err.error || "Unknown error, check console for details"}`
); );
return throwError(
() => `Bad Request: ${err.error?.detail || err.error || "Unknown error, check console for details"}`
);
}
if (err.status == 403) {
console.error(err);
utilsService.toast("error", "Error", `${err.error?.detail || err.error || "You are not allowed to do this"}`);
return throwError(() => `Bad Request: ${err.error?.detail || err.error || "You are not allowed to do this"}`);
}
if (err.status == 409) {
console.error(err);
utilsService.toast("error", "Error", `${err.error?.detail || err.error || "Conflict on resource"}`);
return throwError(() => `Bad Request: ${err.error?.detail || err.error || "Conflict on resource"}`);
}
if (err.status == 413) {
console.error(err);
utilsService.toast(
"error",
"Request entity too large",
"The resource you are trying to upload or create is too big"
);
return throwError(() => "Request entity too large, the resource you are trying to upload or create is too big");
}
if (err.status == 422) {
console.error(err);
utilsService.toast("error", "Unprocessable Entity ", "The resource you sent was unprocessable");
return throwError(() => "Resource sent was unprocessable");
}
if (err.status == 502) {
console.error(err);
utilsService.toast("error", "Bad Gateway", "Check your connectivity and ensure the server is up and running");
return throwError(() => "Bad Request: Check your connectivity and ensure the server is up and running");
}
if (err.status == 503) {
console.error(err);
utilsService.toast(
"error",
"Service Unavailable",
`${err.error?.detail || err.statusText || "Resource not available"}`
);
return throwError(() => "Service Unavailable: Resource not available");
} }
if (err.status == 401 && authService.accessToken) { if (err.status == 401 && authService.accessToken) {
@ -92,25 +80,29 @@ export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Obs
// Unauthenticated, AT exists but is expired (authServices.accessToken truethy), we refresh it // Unauthenticated, AT exists but is expired (authServices.accessToken truethy), we refresh it
return authService.refreshAccessToken().pipe( return authService.refreshAccessToken().pipe(
take(1),
switchMap((tokens) => { switchMap((tokens) => {
req = req.clone({ const refreshedReq = req.clone({
setHeaders: { setHeaders: {
Authorization: `Bearer ${tokens.access_token}`, Authorization: `Bearer ${tokens.access_token}`,
}, },
}); });
return next(req); return next(refreshedReq);
}) }),
); );
} else { } else if (err.status == 401 && !req.url.endsWith("/refresh")) {
// If any API route 401 -> redirect to login. We skip /refresh/ to prevent toast on login errors. // If any API route 401 -> redirect to login. We skip /refresh/ to prevent toast on login errors.
if (!req.url.endsWith("/refresh")) { authService.logout(
if (err instanceof HttpErrorResponse && err.status === 401) { `${err.error?.detail || err.error || "You must be authenticated"}`,
authService.logout(`${err.error?.detail || err.error || "You must be authenticated"}`, true); true,
} );
}
} }
return throwError(() => err); console.error(err);
}) return showAndThrowError(
"Request Error",
`${err.error?.detail || err.error || "Unknown error, check console for details"}`,
);
}),
); );
}; };