feat: normalize API errors and handle 429 rate limiting
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Nothing in the frontend parsed the backend error envelope, and nothing
anywhere handled 429 - a rate-limited backend surfaced as a generic failure
with no retry and no user-visible explanation. core/error-handling and
core/interceptors were empty directories.

- api-error.model.ts    typed envelope per BACKEND-API-REFERENCE.md section 5,
                        plus a status-to-code fallback so a response with no
                        envelope still arrives as a usable ApiError
- api-error.mapper.ts   total function: HTML bodies, empty bodies and
                        differently-shaped JSON all produce an ApiError rather
                        than throwing inside the error path
- api-error.interceptor bounded retry on 429 honouring Retry-After (seconds or
                        HTTP-date), idempotent methods only - replaying a POST
                        after a 429 can double-submit, and that call belongs to
                        the caller that knows whether it holds an idempotency key
- rate-limit-notifier   signal-based state so the UI can say "throttled,
                        resumes in N seconds" instead of "something went wrong";
                        self-clearing, because a banner outliving the throttle
                        trains users to ignore it

A 429 carrying no delay hint defaults to a non-zero wait so callers cannot
busy-loop the endpoint that just asked them to stop.

14 mapper tests. Suite 115/115 green, boundaries pass, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 13:15:13 +04:00
parent a2204c641b
commit 92e2ee5f49
6 changed files with 501 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/ht
import { routes } from './app.routes';
import { cacheInterceptor } from './interceptors/cache.interceptor';
import { apiErrorInterceptor } from './core/interceptors/api-error.interceptor';
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
@@ -22,7 +23,9 @@ export const appConfig: ApplicationConfig = {
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
),
provideHttpClient(withXhr(),
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
// apiErrorInterceptor sits last so it observes the response after every
// other interceptor has run, and normalizes whatever actually came back.
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor, apiErrorInterceptor])
),
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },