Merge branch 'B2B'
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 },
|
||||
|
||||
136
src/app/core/error-handling/api-error.mapper.spec.ts
Normal file
136
src/app/core/error-handling/api-error.mapper.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
||||
import { toApiError } from './api-error.mapper';
|
||||
import { ApiErrorCode } from './models/api-error.model';
|
||||
|
||||
function errorResponse(init: {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
url?: string;
|
||||
}): HttpErrorResponse {
|
||||
return new HttpErrorResponse({
|
||||
status: init.status ?? 500,
|
||||
error: init.body,
|
||||
headers: new HttpHeaders(init.headers ?? {}),
|
||||
url: init.url ?? 'https://api.example.com/api/v2/offers',
|
||||
});
|
||||
}
|
||||
|
||||
describe('toApiError', () => {
|
||||
it('reads a full envelope', () => {
|
||||
// Arrange
|
||||
const response = errorResponse({
|
||||
status: 422,
|
||||
body: {
|
||||
error: {
|
||||
code: 'VALIDATION_FAILED',
|
||||
message: 'One or more fields are invalid.',
|
||||
status: 422,
|
||||
requestId: 'b3f1c2a0',
|
||||
details: [{ field: 'sku', code: 'REQUIRED', message: 'SKU is required.' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
const error = toApiError(response);
|
||||
|
||||
// Assert
|
||||
expect(error.code).toBe('VALIDATION_FAILED');
|
||||
expect(error.status).toBe(422);
|
||||
expect(error.requestId).toBe('b3f1c2a0');
|
||||
expect(error.hasEnvelope).toBeTrue();
|
||||
expect(error.details.length).toBe(1);
|
||||
expect(error.details[0].field).toBe('sku');
|
||||
});
|
||||
|
||||
it('accepts an unwrapped envelope, because some gateways strip the wrapper', () => {
|
||||
const error = toApiError(
|
||||
errorResponse({ status: 409, body: { code: 'CONFLICT', message: 'Already exists.', status: 409 } }),
|
||||
);
|
||||
|
||||
expect(error.code).toBe('CONFLICT');
|
||||
expect(error.hasEnvelope).toBeTrue();
|
||||
});
|
||||
|
||||
it('synthesizes a code when the body carries no envelope', () => {
|
||||
const error = toApiError(errorResponse({ status: 404, body: '<html>Not Found</html>' }));
|
||||
|
||||
expect(error.code).toBe(ApiErrorCode.NOT_FOUND);
|
||||
expect(error.hasEnvelope).toBeFalse();
|
||||
expect(error.details).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps a network failure to NETWORK_UNAVAILABLE', () => {
|
||||
const error = toApiError(errorResponse({ status: 0, body: null }));
|
||||
|
||||
expect(error.code).toBe(ApiErrorCode.NETWORK_UNAVAILABLE);
|
||||
expect(error.status).toBe(0);
|
||||
});
|
||||
|
||||
it('prefers the transport status over a self-reported one', () => {
|
||||
// A proxy can return 502 while the body still claims 200-era metadata.
|
||||
const error = toApiError(
|
||||
errorResponse({ status: 502, body: { error: { code: 'UPSTREAM', message: 'x', status: 200 } } }),
|
||||
);
|
||||
|
||||
expect(error.status).toBe(502);
|
||||
});
|
||||
|
||||
it('reads Retry-After given as seconds', () => {
|
||||
const error = toApiError(
|
||||
errorResponse({ status: 429, body: { error: { code: 'RATE_LIMITED', message: 'slow down', status: 429 } }, headers: { 'Retry-After': '17' } }),
|
||||
);
|
||||
|
||||
expect(error.retryAfterSeconds).toBe(17);
|
||||
});
|
||||
|
||||
it('reads Retry-After given as an HTTP date', () => {
|
||||
const future = new Date(Date.now() + 12_000).toUTCString();
|
||||
|
||||
const error = toApiError(errorResponse({ status: 429, headers: { 'Retry-After': future } }));
|
||||
|
||||
// Allow a second of slack for clock/rounding.
|
||||
expect(error.retryAfterSeconds).toBeGreaterThanOrEqual(11);
|
||||
expect(error.retryAfterSeconds).toBeLessThanOrEqual(13);
|
||||
});
|
||||
|
||||
it('falls back to the body retryAfterSeconds when no header is present', () => {
|
||||
const error = toApiError(
|
||||
errorResponse({ status: 429, body: { error: { code: 'RATE_LIMITED', message: 'x', status: 429, retryAfterSeconds: 9 } } }),
|
||||
);
|
||||
|
||||
expect(error.retryAfterSeconds).toBe(9);
|
||||
});
|
||||
|
||||
it('defaults a 429 with no hint to a non-zero delay, so callers cannot busy-loop', () => {
|
||||
const error = toApiError(errorResponse({ status: 429, body: null }));
|
||||
|
||||
expect(error.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never sets retryAfterSeconds for errors that are not throttled', () => {
|
||||
const error = toApiError(errorResponse({ status: 500, body: null }));
|
||||
|
||||
expect(error.retryAfterSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops malformed detail entries rather than failing', () => {
|
||||
const error = toApiError(
|
||||
errorResponse({
|
||||
status: 422,
|
||||
body: { error: { code: 'VALIDATION_FAILED', message: 'x', status: 422, details: [{ nope: 1 }, 'string', { field: 'sku' }] } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error.details.length).toBe(1);
|
||||
expect(error.details[0].field).toBe('sku');
|
||||
expect(error.details[0].code).toBe(ApiErrorCode.VALIDATION_FAILED);
|
||||
});
|
||||
|
||||
it('treats an envelope with a non-string code as absent', () => {
|
||||
const error = toApiError(errorResponse({ status: 400, body: { error: { code: 42, message: 'x' } } }));
|
||||
|
||||
expect(error.hasEnvelope).toBeFalse();
|
||||
});
|
||||
});
|
||||
130
src/app/core/error-handling/api-error.mapper.ts
Normal file
130
src/app/core/error-handling/api-error.mapper.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
ApiError,
|
||||
ApiErrorCode,
|
||||
ApiErrorDetail,
|
||||
ApiErrorEnvelope,
|
||||
fallbackCodeForStatus,
|
||||
} from './models/api-error.model';
|
||||
|
||||
/**
|
||||
* Turn any HttpErrorResponse into a normalized ApiError.
|
||||
* Contract: BACKEND-API-REFERENCE.md §5.
|
||||
*
|
||||
* Deliberately total: every input produces an ApiError. A backend that answers
|
||||
* with HTML, an empty body, or a differently-shaped JSON object still yields a
|
||||
* usable error rather than throwing inside the error path — a throw there
|
||||
* replaces the real failure with a less informative one.
|
||||
*/
|
||||
export function toApiError(response: HttpErrorResponse): ApiError {
|
||||
const status = response.status ?? 0;
|
||||
const envelope = readEnvelope(response.error);
|
||||
const retryAfterSeconds = readRetryAfter(response, envelope);
|
||||
|
||||
if (envelope) {
|
||||
return {
|
||||
code: envelope.code,
|
||||
message: envelope.message,
|
||||
// Trust the transport status over a self-reported one: a proxy or
|
||||
// gateway can produce a status the application never set.
|
||||
status: status || envelope.status,
|
||||
requestId: envelope.requestId,
|
||||
details: envelope.details ?? [],
|
||||
hasEnvelope: true,
|
||||
retryAfterSeconds,
|
||||
url: response.url ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: fallbackCodeForStatus(status),
|
||||
message: fallbackMessage(status, response),
|
||||
status,
|
||||
details: [],
|
||||
hasEnvelope: false,
|
||||
retryAfterSeconds,
|
||||
url: response.url ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readEnvelope(body: unknown): ApiErrorEnvelope | null {
|
||||
const wrapper = asRecord(body);
|
||||
if (!wrapper) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accept both { error: {...} } and a bare {...}: some gateways unwrap the
|
||||
// envelope, and rejecting that shape would lose a code we actually have.
|
||||
const candidate = asRecord(wrapper['error']) ?? wrapper;
|
||||
|
||||
const code = candidate['code'];
|
||||
const status = candidate['status'];
|
||||
if (typeof code !== 'string' || code.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
message: typeof candidate['message'] === 'string' ? candidate['message'] : code,
|
||||
status: typeof status === 'number' ? status : 0,
|
||||
requestId: typeof candidate['requestId'] === 'string' ? candidate['requestId'] : undefined,
|
||||
details: readDetails(candidate['details']),
|
||||
};
|
||||
}
|
||||
|
||||
function readDetails(value: unknown): readonly ApiErrorDetail[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const details = value
|
||||
.map(asRecord)
|
||||
.filter((entry): entry is Record<string, unknown> => entry !== null)
|
||||
.filter(entry => typeof entry['field'] === 'string')
|
||||
.map(entry => ({
|
||||
field: entry['field'] as string,
|
||||
code: typeof entry['code'] === 'string' ? entry['code'] : ApiErrorCode.VALIDATION_FAILED,
|
||||
message: typeof entry['message'] === 'string' ? entry['message'] : '',
|
||||
}));
|
||||
return details.length > 0 ? details : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry-After per RFC 9110: either delta-seconds or an HTTP-date. The envelope
|
||||
* may also carry retryAfterSeconds (Track S §5); the header wins when both are
|
||||
* present, because it is what the edge actually enforces.
|
||||
*/
|
||||
function readRetryAfter(response: HttpErrorResponse, envelope: ApiErrorEnvelope | null): number | undefined {
|
||||
const header = response.headers?.get('Retry-After');
|
||||
if (header) {
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.ceil(seconds);
|
||||
}
|
||||
const date = Date.parse(header);
|
||||
if (!Number.isNaN(date)) {
|
||||
return Math.max(0, Math.ceil((date - Date.now()) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const fromBody = asRecord(asRecord(response.error)?.['error'] ?? response.error)?.['retryAfterSeconds'];
|
||||
if (typeof fromBody === 'number' && Number.isFinite(fromBody) && fromBody >= 0) {
|
||||
return Math.ceil(fromBody);
|
||||
}
|
||||
|
||||
// A 429 with no hint still needs a delay, or callers busy-loop the endpoint
|
||||
// that just asked them to stop.
|
||||
return envelope?.code === ApiErrorCode.RATE_LIMITED || response.status === 429 ? 5 : undefined;
|
||||
}
|
||||
|
||||
function fallbackMessage(status: number, response: HttpErrorResponse): string {
|
||||
if (status === 0) {
|
||||
return 'Network unavailable.';
|
||||
}
|
||||
return response.message || `Request failed with status ${status}.`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
98
src/app/core/error-handling/models/api-error.model.ts
Normal file
98
src/app/core/error-handling/models/api-error.model.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Typed shape of the backend error envelope.
|
||||
* Contract: BACKEND-API-REFERENCE.md §5.
|
||||
*
|
||||
* Callers branch on `code`, never on `message` — the message is an English
|
||||
* fallback and is free to change without notice.
|
||||
*/
|
||||
|
||||
/** Per-field validation issue. Present only on 422. */
|
||||
export interface ApiErrorDetail {
|
||||
readonly field: string;
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** The envelope body as the backend sends it. */
|
||||
export interface ApiErrorEnvelope {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
readonly requestId?: string;
|
||||
readonly details?: readonly ApiErrorDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized error every HTTP caller sees.
|
||||
*
|
||||
* A response that carries no envelope still arrives as an ApiError, with
|
||||
* `code` synthesized from the HTTP status and `hasEnvelope: false`. That way
|
||||
* no caller needs a second code path for "backend answered in a shape we did
|
||||
* not expect" — the most common case in practice, and the one that silently
|
||||
* breaks error UIs when it is not modelled.
|
||||
*/
|
||||
export interface ApiError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
readonly requestId?: string;
|
||||
readonly details: readonly ApiErrorDetail[];
|
||||
/** False when the code was synthesized from the HTTP status. */
|
||||
readonly hasEnvelope: boolean;
|
||||
/** Seconds to wait before retrying. Only set for 429 and 503. */
|
||||
readonly retryAfterSeconds?: number;
|
||||
/** The URL that failed, for logging. Never rendered to a user. */
|
||||
readonly url?: string;
|
||||
}
|
||||
|
||||
/** Codes the app branches on. Extend as the backend defines more. */
|
||||
export const ApiErrorCode = {
|
||||
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
FX_SOURCE_UNAVAILABLE: 'FX_SOURCE_UNAVAILABLE',
|
||||
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||
NETWORK_UNAVAILABLE: 'NETWORK_UNAVAILABLE',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
export type ApiErrorCodeValue = (typeof ApiErrorCode)[keyof typeof ApiErrorCode];
|
||||
|
||||
/** Status → code, for responses that arrive without an envelope. */
|
||||
const STATUS_CODE_FALLBACK: Readonly<Record<number, string>> = {
|
||||
0: ApiErrorCode.NETWORK_UNAVAILABLE,
|
||||
401: ApiErrorCode.UNAUTHORIZED,
|
||||
403: ApiErrorCode.FORBIDDEN,
|
||||
404: ApiErrorCode.NOT_FOUND,
|
||||
409: ApiErrorCode.CONFLICT,
|
||||
422: ApiErrorCode.VALIDATION_FAILED,
|
||||
429: ApiErrorCode.RATE_LIMITED,
|
||||
503: ApiErrorCode.SERVICE_UNAVAILABLE,
|
||||
};
|
||||
|
||||
export function fallbackCodeForStatus(status: number): string {
|
||||
return STATUS_CODE_FALLBACK[status] ?? ApiErrorCode.UNKNOWN;
|
||||
}
|
||||
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as ApiError).code === 'string' &&
|
||||
typeof (value as ApiError).status === 'number' &&
|
||||
typeof (value as ApiError).hasEnvelope === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/** True when retrying the identical request could plausibly succeed. */
|
||||
export function isRetryable(error: ApiError): boolean {
|
||||
return (
|
||||
error.status === 429 ||
|
||||
error.status === 503 ||
|
||||
error.status === 0 ||
|
||||
error.status === 504
|
||||
);
|
||||
}
|
||||
57
src/app/core/error-handling/rate-limit-notifier.service.ts
Normal file
57
src/app/core/error-handling/rate-limit-notifier.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Makes rate limiting visible to the UI.
|
||||
*
|
||||
* Without this a 429 is indistinguishable from a generic failure: the user
|
||||
* sees "something went wrong" while the correct message is "you are being
|
||||
* throttled, this resumes in N seconds."
|
||||
*/
|
||||
|
||||
export interface RateLimitState {
|
||||
readonly active: boolean;
|
||||
readonly retryAfterSeconds: number;
|
||||
readonly lastUrl?: string;
|
||||
readonly since?: number;
|
||||
}
|
||||
|
||||
const IDLE: RateLimitState = { active: false, retryAfterSeconds: 0 };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RateLimitNotifier {
|
||||
private readonly state = signal<RateLimitState>(IDLE);
|
||||
private clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly current = this.state.asReadonly();
|
||||
readonly isRateLimited = computed(() => this.state().active);
|
||||
readonly retryAfterSeconds = computed(() => this.state().retryAfterSeconds);
|
||||
|
||||
/** Called by the HTTP interceptor when a 429 is observed. */
|
||||
report(retryAfterSeconds: number, url?: string): void {
|
||||
const seconds = Math.max(0, Math.ceil(retryAfterSeconds));
|
||||
|
||||
this.state.set({
|
||||
active: true,
|
||||
retryAfterSeconds: seconds,
|
||||
lastUrl: url,
|
||||
since: Date.now(),
|
||||
});
|
||||
|
||||
// Self-clearing: a banner that outlives the throttle is worse than no
|
||||
// banner, because it trains users to ignore it.
|
||||
this.cancelTimer();
|
||||
this.clearTimer = setTimeout(() => this.clear(), Math.max(1, seconds) * 1000);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cancelTimer();
|
||||
this.state.set(IDLE);
|
||||
}
|
||||
|
||||
private cancelTimer(): void {
|
||||
if (this.clearTimer !== null) {
|
||||
clearTimeout(this.clearTimer);
|
||||
this.clearTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/app/core/interceptors/api-error.interceptor.ts
Normal file
76
src/app/core/interceptors/api-error.interceptor.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { throwError, timer } from 'rxjs';
|
||||
import { catchError, mergeMap, retry } from 'rxjs/operators';
|
||||
import { toApiError } from '../error-handling/api-error.mapper';
|
||||
import { ApiError } from '../error-handling/models/api-error.model';
|
||||
import { RateLimitNotifier } from '../error-handling/rate-limit-notifier.service';
|
||||
|
||||
/**
|
||||
* Normalizes every failed response into an ApiError, and honours 429
|
||||
* Retry-After instead of failing straight through.
|
||||
*
|
||||
* Contract: BACKEND-API-REFERENCE.md §5 (envelope), TRACK-S §5 (rate limits).
|
||||
*
|
||||
* Before this existed, every caller branched on raw HttpErrorResponse.status
|
||||
* and nothing anywhere handled 429 — a rate-limited backend surfaced as a
|
||||
* generic failure with no retry and no user-visible explanation.
|
||||
*/
|
||||
|
||||
/** Retries are bounded: past this, the caller sees the error. */
|
||||
const MAX_RATE_LIMIT_RETRIES = 2;
|
||||
|
||||
/** Never sleep longer than this on a server-suggested delay. */
|
||||
const MAX_RETRY_DELAY_SECONDS = 30;
|
||||
|
||||
export const apiErrorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const notifier = inject(RateLimitNotifier);
|
||||
|
||||
return next(req).pipe(
|
||||
retry({
|
||||
count: MAX_RATE_LIMIT_RETRIES,
|
||||
delay: (error: unknown, retryCount: number) => {
|
||||
if (!(error instanceof HttpErrorResponse) || error.status !== 429) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
// Only retry requests that are safe to repeat. Replaying a POST after
|
||||
// a 429 can double-submit; that decision belongs to the caller, which
|
||||
// knows whether it holds an idempotency key.
|
||||
if (!isIdempotent(req.method)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const apiError = toApiError(error);
|
||||
const waitSeconds = Math.min(
|
||||
apiError.retryAfterSeconds ?? retryCount * 5,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
);
|
||||
|
||||
notifier.report(waitSeconds, req.url);
|
||||
return timer(waitSeconds * 1000);
|
||||
},
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const apiError: ApiError = toApiError(error);
|
||||
|
||||
if (apiError.status === 429) {
|
||||
notifier.report(apiError.retryAfterSeconds ?? 0, req.url);
|
||||
}
|
||||
|
||||
return throwError(() => apiError);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function isIdempotent(method: string): boolean {
|
||||
const m = method.toUpperCase();
|
||||
return m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
|
||||
}
|
||||
|
||||
/** Exported for tests only. */
|
||||
export const __testing = { MAX_RATE_LIMIT_RETRIES, MAX_RETRY_DELAY_SECONDS, isIdempotent };
|
||||
Reference in New Issue
Block a user