Skip to content

fix(http): run root interceptors in the terminal request chain#69778

Open
SkyZeroZx wants to merge 4 commits into
angular:mainfrom
SkyZeroZx:fix/leak-parent-transfercache
Open

fix(http): run root interceptors in the terminal request chain#69778
SkyZeroZx wants to merge 4 commits into
angular:mainfrom
SkyZeroZx:fix/leak-parent-transfercache

Conversation

@SkyZeroZx

@SkyZeroZx SkyZeroZx commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Represent withRequestsMadeViaParent() with an internal delegating backend so the interceptor handler can distinguish delegated clients from independent child configurations.

Delegated clients leave inherited root interceptors to the parent chain. This prevents duplicate execution and lets HttpTransferCache evaluate authentication and cache filters after parent request interceptors, while independent child clients continue to inherit framework root interceptors.

Add coverage for independent root inheritance, request and response ordering, authenticated requests, cache filters, and public cache hits.

Fixes #69777

This seems similar to GHSA-q6f4-qqrg-jv6x ( In fact, no filter or exclusion is currently applied )

@pullapprove
pullapprove Bot requested a review from JeanMeche July 15, 2026 05:00
@angular-robot angular-robot Bot added the area: common/http Issues related to HTTP and HTTP Client label Jul 15, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jul 15, 2026
Comment thread packages/common/http/src/provider.ts Outdated
);
}
return handlerFromParent;
return new HttpParentBackend(handlerFromParent!);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you think of defining a separate token instead of creating a separate HttpBackend instance ?

@SkyZeroZx SkyZeroZx Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you referring to using a DI token as a boolean flag here?

I considered it, but after reviewing the git history, it seems we might have a case like this #55652

A boolean token could become stale if another provider overrides HttpBackend, causing inherited root interceptors to be skipped incorrectly.


Or did you mean using a token for the parent handler and comparing it with the current HttpBackend instance?

In that case, I believe it would be something like the following (I haven't tested it), personally I would prefer the class that seemed simpler to me

const HTTP_PARENT_HANDLER = new InjectionToken<HttpHandler>('HTTP_PARENT_HANDLER');

// withRequestsMadeViaParent()
[
 {
   provide: HTTP_PARENT_HANDLER,
   useFactory: () => inject(HttpHandler, {skipSelf: true}),
 },
 {
   provide: HttpBackend,
   useExisting: HTTP_PARENT_HANDLER,
 },
];

// HttpInterceptorHandler
private readonly parentHandler = inject(HTTP_PARENT_HANDLER, {
 self: true,
 optional: true,
});

const rootInterceptorFns = this.injector.get(
 HTTP_ROOT_INTERCEPTOR_FNS,
 [],
 this.backend === this.parentHandler ? {self: true} : undefined,
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was more think of something like:

export const ɵHTTP_CLIENT_IS_DELEGATING = new InjectionToken<boolean>('ɵHTTP_CLIENT_IS_DELEGATING');

// packages/common/http/src/provider.ts
export function withRequestsMadeViaParent(): HttpFeature<HttpFeatureKind.RequestsMadeViaParent> {
  return makeHttpFeature(HttpFeatureKind.RequestsMadeViaParent, [
    {
      provide: HttpBackend,
      useFactory: () => {
        const handlerFromParent = inject(HttpHandler, {skipSelf: true, optional: true});
        // ... error handling ...
        return handlerFromParent; // Return directly
      },
    },
    {
      provide: ɵHTTP_CLIENT_IS_DELEGATING,
      useValue: true
    }
  ]);
}

// packages/common/http/src/backend.ts (Inside HttpInterceptorHandler)
handle(initialRequest: HttpRequest<any>): Observable<HttpEvent<any>> {
  if (this.chain === null) {
    // Query with {self: true} to strictly check if the *current* client configuration is delegating
    const isDelegating = this.injector.get(ɵHTTP_CLIENT_IS_DELEGATING, false, {self: true});

    const rootInterceptorFns = this.injector.get(
      HTTP_ROOT_INTERCEPTOR_FNS,
      [],
      isDelegating ? {self: true} : undefined,
    );
    // ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this approach, but the token can become stale if another feature overrides HttpBackend.

Eg : with withRequestsMadeViaParent() and withXhr(), XHR becomes the effective backend while the token remains true, so inherited root interceptors are skipped. This test reproduces it.

it('should inherit root interceptors when withXhr overrides parent delegation', () => {
      TestBed.configureTestingModule({
        providers: [
          provideHttpClient(),
          {
            provide: HTTP_ROOT_INTERCEPTOR_FNS,
            useValue: makeLiteralTagInterceptorFn('root'),
            multi: true,
          },
        ],
      });

      const child = createEnvironmentInjector(
        [
          provideHttpClient(withRequestsMadeViaParent(), withXhr()),
          {provide: XhrFactory, useClass: MockXhrFactory},
        ],
        TestBed.inject(EnvironmentInjector),
      );

      try {
        child.get(HttpClient).get('/test').subscribe();
        const factory = child.get(XhrFactory) as MockXhrFactory;
        expect(factory.mock.mockHeaders['X-Tag']).toBe('root');
        factory.mock.mockFlush(200, 'OK', '{}');
      } finally {
        child.destroy();
      }
    });

@JeanMeche JeanMeche Jul 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the issue here is that we allow this contradictory usage. Both withRequestsMadeViaParent() and withXhr() register a different backend.

@SkyZeroZx SkyZeroZx Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that could be the case too; in fact, I don't know if it's intentional or not.

But I think a feature on some route might want to use withXhr while maintaining interceptors for the upload report, which isn't supported in fetch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both provide HttpBackend only the latest registred will effectively be injected.

@SkyZeroZx SkyZeroZx Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad , you're right, we'll most likely have to throw an exception in this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, we will now throw an exception at development time, although I still think we should allow a custom HttpBackend for example in provideHttpClientTesting

@SkyZeroZx
SkyZeroZx requested a review from JeanMeche July 16, 2026 15:30
@JeanMeche
JeanMeche requested review from alan-agius4 and removed request for JeanMeche July 17, 2026 15:38
@alan-agius4 alan-agius4 added the action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews label Jul 20, 2026
@SkyZeroZx
SkyZeroZx force-pushed the fix/leak-parent-transfercache branch 2 times, most recently from 449ce42 to 26df123 Compare July 20, 2026 16:56
@SkyZeroZx
SkyZeroZx requested a review from JeanMeche July 20, 2026 18:50
@JeanMeche

JeanMeche commented Jul 21, 2026

Copy link
Copy Markdown
Member

Found a regression/change:
Prior to this change the interceptor would run twice, now it only runs ones.

import { TestBed } from '@angular/core/testing';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { HttpClient, provideHttpClient, withRequestsMadeViaParent } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HTTP_ROOT_INTERCEPTOR_FNS } from '@angular/common/http/src/interceptor';
describe('Delegating HttpClient root interceptors', () => {
  it('should only run root interceptors once in a delegated chain', () => {
    let rootInterceptorRunCount = 0;
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        {
          provide: HTTP_ROOT_INTERCEPTOR_FNS,
          multi: true,
          useValue: (req, next) => {
            rootInterceptorRunCount++;
            return next(req);
          }
        }
      ],
    });
    const childInjector = createEnvironmentInjector(
      [provideHttpClient(withRequestsMadeViaParent())],
      TestBed.inject(EnvironmentInjector)
    );
    const client = childInjector.get(HttpClient);
    const httpMock = TestBed.inject(HttpTestingController);
    client.get('/api/data').subscribe();
    httpMock.expectOne('/api/data').flush({});
    // ❌ FAILS BEFORE PR: The count would be 2 because the child executed the parent's 
    // root interceptors, and then the parent executed them again.
    expect(rootInterceptorRunCount).toBe(1); 
  });
});

@SkyZeroZx

Copy link
Copy Markdown
Contributor Author

Found a regression/change: Prior to this change the interceptor would run twice, now it only runs ones.

import { TestBed } from '@angular/core/testing';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { HttpClient, provideHttpClient, withRequestsMadeViaParent } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HTTP_ROOT_INTERCEPTOR_FNS } from '@angular/common/http/src/interceptor';
describe('Delegating HttpClient root interceptors', () => {
  it('should only run root interceptors once in a delegated chain', () => {
    let rootInterceptorRunCount = 0;
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        {
          provide: HTTP_ROOT_INTERCEPTOR_FNS,
          multi: true,
          useValue: (req, next) => {
            rootInterceptorRunCount++;
            return next(req);
          }
        }
      ],
    });
    const childInjector = createEnvironmentInjector(
      [provideHttpClient(withRequestsMadeViaParent())],
      TestBed.inject(EnvironmentInjector)
    );
    const client = childInjector.get(HttpClient);
    const httpMock = TestBed.inject(HttpTestingController);
    client.get('/api/data').subscribe();
    httpMock.expectOne('/api/data').flush({});
    // ❌ FAILS BEFORE PR: The count would be 2 because the child executed the parent's 
    // root interceptors, and then the parent executed them again.
    expect(rootInterceptorRunCount).toBe(1); 
  });
});

In fact, I believe it was a bug and not something expected to happen twice (or so I think), since that's why the problem described in #69777 occurs.

I doubt we would expect or that it would make any sense to expect an interceptor to execute twice (when you create an interceptor in an app, this doesn't happen).

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Great PR! The tests are very thorough and it effectively fixes the duplicate root interceptor execution. I have left a few inline suggestions for your consideration to simplify the implementation by removing the internal injection token.

Comment thread packages/common/http/src/backend.ts Outdated
}

/** Indicates that the current `HttpClient` delegates requests to its parent. */
export const ɵHTTP_CLIENT_IS_DELEGATING = new InjectionToken<boolean>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: If you apply the suggestion below to check the handler directly, this token and its provider can be safely removed.

Comment thread packages/common/http/src/backend.ts Outdated

handle(initialRequest: HttpRequest<any>): Observable<HttpEvent<any>> {
if (this.chain === null) {
const isDelegating = this.injector.get(ɵHTTP_CLIENT_IS_DELEGATING, false, {self: true});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: We can simplify this logic by checking if this.backend is the parent HttpHandler directly. This allows us to completely remove the ɵHTTP_CLIENT_IS_DELEGATING token and its associated provider in provider.ts, keeping the API surface smaller and saving a provider initialization.

Suggested change
const isDelegating = this.injector.get(ɵHTTP_CLIENT_IS_DELEGATING, false, {self: true});
const parentHandler = this.injector.get(HttpHandler, null, {skipSelf: true});
const isDelegating = parentHandler !== null && this.backend === parentHandler;

Comment thread packages/common/http/src/provider.ts Outdated
},
{
provide: ɵHTTP_CLIENT_IS_DELEGATING,
// `HttpBackend` can be overridden by later providers, so derive this flag from the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: If we evaluate the delegation condition directly in HttpInterceptorHandler.handle, this provider can be removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Represent withRequestsMadeViaParent() with an internal delegating backend so the interceptor handler can distinguish delegated clients from independent child configurations.

Delegated clients leave inherited root interceptors to the parent chain. This prevents duplicate execution and lets HttpTransferCache evaluate authentication and cache filters after parent request interceptors, while independent child clients continue to inherit framework root interceptors.

Add coverage for independent root inheritance, request and response ordering, authenticated requests, cache filters, and public cache hits.

Fixes angular#69777
@SkyZeroZx
SkyZeroZx force-pushed the fix/leak-parent-transfercache branch from cb9fbd6 to 51616ee Compare July 22, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews area: common/http Issues related to HTTP and HTTP Client

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HttpTransferCache can cache authenticated responses when using withRequestsMadeViaParent

3 participants