1. HttpClient Basics: Typed Requests
You provided HttpClient back in Week 3 with provideHttpClient()
— this week it finally gets used. Every method returns an Observable, and every method
accepts a generic type parameter for the typed response.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Product {
id: string;
name: string;
price: number;
}
@Injectable({ providedIn: 'root' })
export class ProductsService {
private http = inject(HttpClient);
private baseUrl = '/api/products';
getAll(): Observable<Product[]> {
return this.http.get<Product[]>(this.baseUrl);
}
getById(id: string): Observable<Product> {
return this.http.get<Product>(`${this.baseUrl}/${id}`);
}
create(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.baseUrl, product);
}
update(id: string, changes: Partial<Product>): Observable<Product> {
return this.http.patch<Product>(`${this.baseUrl}/${id}`, changes);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
HttpClient returns Observables, not Promises — nothing happens until
something subscribes (directly, via | async in a template, or via
toSignal()/rxResource() in Week 16). Forgetting to subscribe
is a real, easy-to-make bug: the request is simply never sent.
2. Functional Interceptors
An interceptor sits between every request your app makes and the network — the single place to attach an auth header, log requests, or handle errors globally, instead of repeating that logic in every service method.
import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.token();
if (!token) {
return next(req);
}
const cloned = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(cloned);
};
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';
import { loggingInterceptor } from './logging.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor])),
],
};
Requests are immutable — that's why the interceptor calls
req.clone() rather than mutating req directly. Multiple
interceptors run in the order you list them, each passing its (possibly modified)
request to next().
3. Retry, Timeout & Error-Recovery Strategies
Networks fail. RxJS operators handle recovery declaratively, right in the request pipeline, instead of wrapping every call site in manual retry logic.
import { timeout, retry, catchError, throwError } from 'rxjs';
getAll(): Observable<Product[]> {
return this.http.get<Product[]>(this.baseUrl).pipe(
timeout(8000),
retry({ count: 2, delay: 1000 }), // retry twice, 1s apart, on any error
catchError((err) => {
console.error('Failed to load products', err);
return throwError(() => new Error('Could not load products. Please try again.'));
})
);
}
catchError here re-throws a cleaner, user-facing error rather than
swallowing the failure — the component calling this method still needs to know the
request failed; it just doesn't need to know it was specifically a
HttpErrorResponse with a particular status code.
Retrying blindly is dangerous for non-idempotent requests — retrying a failed POST that creates an order could create it twice if the first attempt actually succeeded but the response was lost. Reach for automatic retry primarily on GET requests, or ensure your backend is idempotent (e.g. via an idempotency key) before retrying anything that mutates data.
4. File Uploads & Progress Events
Uploading a file is a normal POST with FormData as the body —
the only addition is opting into progress events, since a large upload can take long
enough that a progress bar genuinely matters.
import { HttpClient, HttpEventType } from '@angular/common/http';
import { map, filter } from 'rxjs';
uploadAvatar(file: File) {
const formData = new FormData();
formData.append('file', file);
return this.http
.post('/api/avatar', formData, { reportProgress: true, observe: 'events' })
.pipe(
filter((event) => event.type === HttpEventType.UploadProgress || event.type === HttpEventType.Response),
map((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
return { percent: Math.round((100 * event.loaded) / event.total) };
}
return { percent: 100, done: true };
})
);
}
reportProgress: true and observe: 'events' are both required —
without them, HttpClient emits only the final response, with no progress
events along the way.
5. Mocking HTTP in Development
Building against a backend that doesn't exist yet (or is slow to run locally) is common
enough to plan for. HttpClientTestingModule's HttpTestingController
is the standard tool for tests (you'll use it directly in Week 18); for interactive
development, a functional interceptor that intercepts and fakes specific requests works
well:
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { environment } from '../environments/environment';
export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
if (!environment.useMockApi) {
return next(req);
}
if (req.url.endsWith('/api/products') && req.method === 'GET') {
return of(new HttpResponse({ status: 200, body: [{ id: '1', name: 'Demo Product', price: 9.99 }] }));
}
return next(req);
};
Gating it behind an environment.useMockApi flag (Week 9's pattern) means
flipping one value switches the whole app between mock and real data — no code changes
required in any component or service.
6. API Versioning & Environment-Based Base URLs
Combine Week 9's InjectionToken pattern with environment files so your base
URL — and API version — change per environment without touching a single service:
import { environment } from '../environments/environment';
import { API_CONFIG } from './api-config';
providers: [
{
provide: API_CONFIG,
useValue: { baseUrl: environment.apiUrl, version: 'v2' },
},
]
private config = inject(API_CONFIG);
private baseUrl = `${this.config.baseUrl}/${this.config.version}/products`;
Every service built this way picks up the right base URL and version automatically per environment — production, staging, local — with zero conditional logic scattered through your data-access layer.
7. Hands-on Exercise
Build a typed API client layer with shared error handling
A production-shaped data layer for a small "products" feature.
Requirements:
- A typed
ProductsServicecovering all five HTTP methods from Section 1, using anAPI_CONFIGtoken for its base URL. - A shared
errorHandlingInterceptorthat catches anyHttpErrorResponse, logs it, and re-throws a cleanErrorwith a user-facing message derived from the status code (400 → "Invalid request", 401 → "Please log in", 500+ → "Something went wrong"). - A
retryInterceptor— or a per-callretry()— applied only toGETrequests, per this lesson's guidance on idempotency. - A
mockApiInterceptor, gated behind an environment flag, returning fake product data so the feature is fully demoable with no real backend. - A component consuming
ProductsServicethat shows a loading state, the resolved list, and the clean error message on failure — simulate a failure by pointing the mock interceptor at an error response for one specific request.
Interceptor order matters — register mockApiInterceptor before authInterceptor and errorHandlingInterceptor in the array, so mocked requests still short-circuit before the others run, and check whether your mock should also simulate the error-handling interceptor's behavior for the failure case to look realistic.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
You call this.http.get(...) in a service method but never subscribe to the result anywhere. What happens?
You call this.http.get(...) in a service method but never subscribe to the result anywhere. What happens?
Nothing — the request is never actually sent. HttpClient methods return cold Observables, meaning no work happens until something subscribes, whether that's a manual .subscribe() call, the async pipe in a template, or a signal-interop function like toSignal().
Q2
Why does authInterceptor call req.clone() instead of setting the header directly on req?
Why does authInterceptor call req.clone() instead of setting the header directly on req?
HttpRequest objects are immutable by design — there's no direct mutation API, only .clone(), which returns a new request with the specified overrides applied. This makes request handling predictable across multiple interceptors: each one gets a request it can safely inspect, and any change is explicit and traceable rather than a silent in-place mutation.
Q3
Why does this lesson recommend limiting automatic retries mostly to GET requests?
Why does this lesson recommend limiting automatic retries mostly to GET requests?
GET requests are idempotent — running the same one multiple times has no additional side effects, so retrying is safe. A POST that creates a resource might have actually succeeded server-side even though the response was lost (a timeout, a dropped connection) — blindly retrying it risks creating the same resource twice, unless the backend specifically guards against that (e.g. with an idempotency key).
Q4
What two options does HttpClient.post() need for you to receive upload progress events instead of just the final response?
What two options does HttpClient.post() need for you to receive upload progress events instead of just the final response?
{ reportProgress: true, observe: 'events' }. Without reportProgress, Angular doesn't bother emitting progress events at all; without observe: 'events', the Observable only ever emits the final parsed response body, with no intermediate events of any kind to filter for.