feat: normalize API errors and handle 429 rate limiting
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user