import { ApplicationConfig } from '@angular/core';
import {
provideHttpClient,
withInterceptors,
} from '@angular/common/http';
import { retryInterceptor } from './retry.interceptor';
import { RETRY_CONFIG } from './backoff';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([retryInterceptor])),
{
provide: RETRY_CONFIG,
useValue: {
maxRetries: 4,
baseDelayMs: 250,
maxDelayMs: 10000,
retryableStatuses: [429, 500, 502, 503, 504],
},
},
],
};
import { InjectionToken } from '@angular/core';
export interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
retryableStatuses: number[];
}
export const RETRY_CONFIG = new InjectionToken<RetryConfig>('RETRY_CONFIG', {
factory: () => ({
maxRetries: 3,
baseDelayMs: 300,
maxDelayMs: 8000,
retryableStatuses: [429, 502, 503, 504],
}),
});
export function computeDelay(attempt: number, cfg: RetryConfig): number {
const exponential = cfg.baseDelayMs * Math.pow(2, attempt - 1);
const capped = Math.min(exponential, cfg.maxDelayMs);
return Math.round(Math.random() * capped); // full jitter
}
export function isRetryable(status: number, cfg: RetryConfig): boolean {
if (status === 0) {
return true; // network / CORS / offline failure
}
return cfg.retryableStatuses.includes(status);
}
import { inject } from '@angular/core';
import {
HttpInterceptorFn,
HttpErrorResponse,
} from '@angular/common/http';
import { retry, timer, throwError } from 'rxjs';
import { RETRY_CONFIG, computeDelay, isRetryable } from './backoff';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
const cfg = inject(RETRY_CONFIG);
// Only replay safe, idempotent verbs to avoid duplicate side effects.
if (req.method !== 'GET') {
return next(req);
}
return next(req).pipe(
retry({
count: cfg.maxRetries,
delay: (error, retryCount) => {
const status =
error instanceof HttpErrorResponse ? error.status : 0;
if (retryCount > cfg.maxRetries || !isRetryable(status, cfg)) {
return throwError(() => error);
}
const wait = computeDelay(retryCount, cfg);
return timer(wait);
},
}),
);
};
This snippet shows a production-ready HTTP resilience layer for Angular built around a functional HttpInterceptorFn and a small, testable backoff policy. The core idea is that transient failures — network blips, 503s during a deploy, rate-limit 429s — should not surface to the caller on the first attempt. Instead the interceptor transparently re-issues the request a bounded number of times, spacing attempts out with exponential backoff so the server gets breathing room rather than a thundering herd.
In backoff.ts, the policy is isolated from Angular entirely so it can be unit tested in isolation. computeDelay doubles a base delay per attempt (base * 2^attempt), caps it at maxDelayMs, and then applies full jitter with Math.random(). Jitter matters: without it, many clients that failed at the same instant retry in lockstep and re-collide. isRetryable centralises the decision of what deserves a retry — network errors (status === 0) plus a small allowlist of idempotent-friendly status codes — keeping that logic out of the RxJS pipeline.
In retry.interceptor.ts, the request is wrapped with RxJS retry, whose delay callback receives both the thrown error and the 1-based retry count. The callback re-checks isRetryable and the attempt ceiling: if either fails it rethrows via throwError, ending the stream with the original HttpErrorResponse so downstream error handlers behave normally. Otherwise it returns a timer observable, which retry subscribes to before re-running the source request. A guard skips non-GET verbs, since blindly replaying a POST risks duplicate side effects unless the endpoint is idempotent. The RETRY_CONFIG injection token lets each app override attempts and timing without editing the interceptor.
app.config.ts wires everything up with provideHttpClient(withInterceptors(...)), the modern standalone approach that replaces the old class-based HTTP_INTERCEPTORS multi-provider. The token is provided here so tests or feature modules can swap in aggressive or disabled policies. The main trade-off is latency versus reliability: retries hide failures but can delay a genuinely dead request by seconds, so the caps and the retryable allowlist keep worst-case delay bounded and avoid hammering endpoints that will never recover.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
class FeatureFlag < ApplicationRecord
validates :key, presence: true, uniqueness: true
validates :percentage, inclusion: { in: 0..100 }
after_commit :expire_cache
Safer Feature Flagging: Cache + DB Fallback
class AddUniqueIndexToTags < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def up
execute <<~SQL
UPDATE tags SET name = LOWER(TRIM(name)) WHERE name IS NOT NULL;
Safer “find or create” with Unique Constraint + Retry
Share this code
Here's the card — post it anywhere.