diff --git a/lib/util/http/bitbucket-server.ts b/lib/util/http/bitbucket-server.ts
index e458d9a63a26831716307f016be03a3597b6420a..ddb676f2da6e0ea34238eaba1acf551a9764782d 100644
--- a/lib/util/http/bitbucket-server.ts
+++ b/lib/util/http/bitbucket-server.ts
@@ -1,7 +1,6 @@
 import is from '@sindresorhus/is';
-import type { InternalJsonUnsafeOptions } from './http';
+import { HttpBase, type InternalJsonUnsafeOptions } from './http';
 import type { HttpMethod, HttpOptions, HttpResponse } from './types';
-import { Http } from '.';
 
 const MAX_LIMIT = 100;
 
@@ -20,7 +19,7 @@ interface PagedResult<T = unknown> {
   values: T[];
 }
 
-export class BitbucketServerHttp extends Http<BitbucketServerHttpOptions> {
+export class BitbucketServerHttp extends HttpBase<BitbucketServerHttpOptions> {
   protected override get baseUrl(): string | undefined {
     return baseUrl;
   }
diff --git a/lib/util/http/bitbucket.ts b/lib/util/http/bitbucket.ts
index bee3ca1c84c2d9d51d3468ac565e52319a74a0d4..6a989bf41b8105cce0bfb17632c109eafae066fc 100644
--- a/lib/util/http/bitbucket.ts
+++ b/lib/util/http/bitbucket.ts
@@ -1,8 +1,7 @@
 import is from '@sindresorhus/is';
 import type { PagedResult } from '../../modules/platform/bitbucket/types';
-import type { InternalJsonUnsafeOptions } from './http';
+import { HttpBase, type InternalJsonUnsafeOptions } from './http';
 import type { HttpMethod, HttpOptions, HttpResponse } from './types';
-import { Http } from '.';
 
 const MAX_PAGES = 100;
 const MAX_PAGELEN = 100;
@@ -18,7 +17,7 @@ export interface BitbucketHttpOptions extends HttpOptions {
   pagelen?: number;
 }
 
-export class BitbucketHttp extends Http<BitbucketHttpOptions> {
+export class BitbucketHttp extends HttpBase<BitbucketHttpOptions> {
   protected override get baseUrl(): string | undefined {
     return baseUrl;
   }
@@ -63,12 +62,11 @@ export class BitbucketHttp extends Http<BitbucketHttpOptions> {
 
       // Override other page-related attributes
       resultBody.pagelen = resultBody.values.length;
+      /* v8 ignore start: hard to test all branches */
       resultBody.size =
-        page <= MAX_PAGES
-          ? resultBody.values.length
-          : /* v8 ignore next */ undefined;
-      resultBody.next =
-        page <= MAX_PAGES ? nextURL : /* v8 ignore next */ undefined;
+        page <= MAX_PAGES ? resultBody.values.length : undefined;
+      resultBody.next = page <= MAX_PAGES ? nextURL : undefined;
+      /* v8 ignore stop */
     }
 
     return result as HttpResponse<T>;
diff --git a/lib/util/http/errors.ts b/lib/util/http/errors.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b9a20c28c40699087900fea1d4ec32357c973749
--- /dev/null
+++ b/lib/util/http/errors.ts
@@ -0,0 +1,2 @@
+// required for zod type safety with `Result.wrap`
+export class EmptyResultError extends Error {}
diff --git a/lib/util/http/gerrit.ts b/lib/util/http/gerrit.ts
index babd02bf43a99d719a45516071283803217abd70..d999d017c28d2d159d6b000e8a3dcca5ecd47257 100644
--- a/lib/util/http/gerrit.ts
+++ b/lib/util/http/gerrit.ts
@@ -1,9 +1,8 @@
 import { parseJson } from '../common';
 import { regEx } from '../regex';
 import { isHttpUrl } from '../url';
-import type { InternalHttpOptions } from './http';
+import { HttpBase, type InternalHttpOptions } from './http';
 import type { HttpOptions } from './types';
-import { Http } from '.';
 
 let baseUrl: string;
 export function setBaseUrl(url: string): void {
@@ -14,7 +13,7 @@ export function setBaseUrl(url: string): void {
  * Access Gerrit REST-API and strip-of the "magic prefix" from responses.
  * @see https://gerrit-review.googlesource.com/Documentation/rest-api.html
  */
-export class GerritHttp extends Http {
+export class GerritHttp extends HttpBase {
   private static magicPrefix = regEx(/^\)]}'\n/g);
 
   protected override get baseUrl(): string | undefined {
diff --git a/lib/util/http/gitea.ts b/lib/util/http/gitea.ts
index 51fb0cba281446e38dd379e6ac189ec6ef9db7f6..1e52806b04ee5fa1199c9d9cea2c91e3de0cd428 100644
--- a/lib/util/http/gitea.ts
+++ b/lib/util/http/gitea.ts
@@ -1,7 +1,6 @@
 import is from '@sindresorhus/is';
-import type { InternalJsonUnsafeOptions } from './http';
+import { HttpBase, type InternalJsonUnsafeOptions } from './http';
 import type { HttpMethod, HttpOptions, HttpResponse } from './types';
-import { Http } from '.';
 
 let baseUrl: string;
 export const setBaseUrl = (newBaseUrl: string): void => {
@@ -24,7 +23,7 @@ function getPaginationContainer<T = unknown>(body: unknown): T[] | null {
   return null;
 }
 
-export class GiteaHttp extends Http<GiteaHttpOptions> {
+export class GiteaHttp extends HttpBase<GiteaHttpOptions> {
   protected override get baseUrl(): string | undefined {
     return baseUrl;
   }
diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts
index b1f45271cd0f061a9685c497790f7c4e71eec900..9958543494bbf0b46146b199499efe39bb7ea961 100644
--- a/lib/util/http/github.ts
+++ b/lib/util/http/github.ts
@@ -15,7 +15,11 @@ import { range } from '../range';
 import { regEx } from '../regex';
 import { joinUrlParts, parseLinkHeader } from '../url';
 import { findMatchingRule } from './host-rules';
-import type { InternalHttpOptions, InternalJsonUnsafeOptions } from './http';
+import {
+  HttpBase,
+  type InternalHttpOptions,
+  type InternalJsonUnsafeOptions,
+} from './http';
 import type { GotLegacyError } from './legacy';
 import type {
   GraphqlOptions,
@@ -23,7 +27,6 @@ import type {
   HttpOptions,
   HttpResponse,
 } from './types';
-import { Http } from '.';
 
 const githubBaseUrl = 'https://api.github.com/';
 let baseUrl = githubBaseUrl;
@@ -31,11 +34,14 @@ export const setBaseUrl = (url: string): void => {
   baseUrl = url;
 };
 
-export interface GithubHttpOptions extends HttpOptions {
+export interface GithubBaseHttpOptions extends HttpOptions {
+  repository?: string;
+}
+
+export interface GithubHttpOptions extends GithubBaseHttpOptions {
   paginate?: boolean | string;
   paginationField?: string;
   pageLimit?: number;
-  repository?: string;
 }
 
 interface GithubGraphqlRepoData<T = unknown> {
@@ -58,7 +64,7 @@ export type GithubGraphqlResponse<T = unknown> =
 function handleGotError(
   err: GotLegacyError,
   url: string | URL,
-  opts: GithubHttpOptions,
+  opts: GithubBaseHttpOptions,
 ): Error {
   const path = url.toString();
   let message = err.message || '';
@@ -267,18 +273,18 @@ function replaceUrlBase(url: URL, baseUrl: string): URL {
   return new URL(relativeUrl, baseUrl);
 }
 
-export class GithubHttp extends Http<GithubHttpOptions> {
+export class GithubHttp extends HttpBase<GithubHttpOptions> {
   protected override get baseUrl(): string | undefined {
     return baseUrl;
   }
 
-  constructor(hostType = 'github', options?: GithubHttpOptions) {
+  constructor(hostType = 'github', options?: HttpOptions) {
     super(hostType, options);
   }
 
   protected override processOptions(
     url: URL,
-    opts: InternalHttpOptions & GithubHttpOptions,
+    opts: InternalHttpOptions & GithubBaseHttpOptions,
   ): void {
     if (!opts.token) {
       const authUrl = new URL(url);
@@ -415,7 +421,7 @@ export class GithubHttp extends Http<GithubHttpOptions> {
     }
     const body = variables ? { query, variables } : { query };
 
-    const opts: GithubHttpOptions = {
+    const opts: GithubBaseHttpOptions = {
       baseUrl: baseUrl.replace('/v3/', '/'), // GHE uses unversioned graphql path
       body,
       headers: { accept: options?.acceptHeader },
@@ -524,9 +530,9 @@ export class GithubHttp extends Http<GithubHttpOptions> {
    */
   public async getRawTextFile(
     url: string,
-    options: InternalHttpOptions & GithubHttpOptions = {},
+    options: InternalHttpOptions & GithubBaseHttpOptions = {},
   ): Promise<HttpResponse> {
-    const newOptions: InternalHttpOptions & GithubHttpOptions = {
+    const newOptions: InternalHttpOptions & GithubBaseHttpOptions = {
       ...options,
       headers: {
         accept: 'application/vnd.github.raw+json',
diff --git a/lib/util/http/gitlab.ts b/lib/util/http/gitlab.ts
index 4bdda9dd7ee21dc4dbf55969726d390ede098a92..8f9826c44402cf02b795e10b2a7d1a2137420406 100644
--- a/lib/util/http/gitlab.ts
+++ b/lib/util/http/gitlab.ts
@@ -3,9 +3,8 @@ import { RequestError, type RetryObject } from 'got';
 import { logger } from '../../logger';
 import { ExternalHostError } from '../../types/errors/external-host-error';
 import { parseLinkHeader, parseUrl } from '../url';
-import type { InternalJsonUnsafeOptions } from './http';
+import { HttpBase, type InternalJsonUnsafeOptions } from './http';
 import type { HttpMethod, HttpOptions, HttpResponse } from './types';
-import { Http } from '.';
 
 let baseUrl = 'https://gitlab.com/api/v4/';
 export const setBaseUrl = (url: string): void => {
@@ -16,7 +15,7 @@ export interface GitlabHttpOptions extends HttpOptions {
   paginate?: boolean;
 }
 
-export class GitlabHttp extends Http<GitlabHttpOptions> {
+export class GitlabHttp extends HttpBase<GitlabHttpOptions> {
   protected override get baseUrl(): string | undefined {
     return baseUrl;
   }
diff --git a/lib/util/http/got.ts b/lib/util/http/got.ts
new file mode 100644
index 0000000000000000000000000000000000000000..34a6cf3c86c30974c2dd2b21bb41164ab969ffd7
--- /dev/null
+++ b/lib/util/http/got.ts
@@ -0,0 +1,64 @@
+// TODO: refactor code to remove this (#9651)
+import './legacy';
+
+import type { Options } from 'got';
+import got, { RequestError } from 'got';
+import type { SetRequired } from 'type-fest';
+import { logger } from '../../logger';
+import { coerceNumber } from '../number';
+import { type HttpRequestStatsDataPoint, HttpStats } from '../stats';
+import { coerceString } from '../string';
+import { hooks } from './hooks';
+import type { GotBufferOptions, GotOptions, HttpResponse } from './types';
+
+export { RequestError } from 'got';
+
+type QueueStatsData = Pick<HttpRequestStatsDataPoint, 'queueMs'>;
+
+export async function fetch(
+  url: string,
+  options: SetRequired<GotOptions, 'method'>,
+  queueStats: QueueStatsData,
+): Promise<HttpResponse<unknown>> {
+  logger.trace({ url, options }, 'got request');
+
+  let duration = 0;
+  let statusCode = 0;
+  try {
+    // Cheat the TS compiler using `as` to pick a specific overload.
+    // Otherwise it doesn't typecheck.
+    const resp = await got(url, { ...options, hooks } as GotBufferOptions);
+    statusCode = resp.statusCode;
+    duration = coerceNumber(resp.timings.phases.total, 0);
+    return resp;
+  } catch (error) {
+    if (error instanceof RequestError) {
+      statusCode = coerceNumber(error.response?.statusCode, -1);
+      duration = coerceNumber(error.timings?.phases.total, -1);
+      const method = options.method.toUpperCase();
+      const code = coerceString(error.code, 'UNKNOWN');
+      const retryCount = coerceNumber(error.request?.retryCount, -1);
+      logger.debug(
+        `${method} ${url} = (code=${code}, statusCode=${statusCode} retryCount=${retryCount}, duration=${duration})`,
+      );
+    }
+
+    throw error;
+    /* v8 ignore next: 🐛 https://github.com/bcoe/c8/issues/229 */
+  } finally {
+    HttpStats.write({
+      method: options.method,
+      url,
+      reqMs: duration,
+      queueMs: queueStats.queueMs,
+      status: statusCode,
+    });
+  }
+}
+
+export function stream(
+  url: string,
+  options: Omit<Options, 'isStream'>,
+): NodeJS.ReadableStream {
+  return got.stream(url, options);
+}
diff --git a/lib/util/http/http.ts b/lib/util/http/http.ts
index 589bce68527dbedcfbe0a727e8078d371e32408b..c9c32d03f1853e97706f8703a651345c671d6d48 100644
--- a/lib/util/http/http.ts
+++ b/lib/util/http/http.ts
@@ -1,7 +1,36 @@
-import type { Options } from 'got';
-import type { SetRequired } from 'type-fest';
-import type { ZodType } from 'zod';
-import type { GotOptions, HttpMethod, HttpOptions } from './types';
+import is from '@sindresorhus/is';
+import merge from 'deepmerge';
+import type { Options, RetryObject } from 'got';
+import type { Merge, SetRequired } from 'type-fest';
+import type { infer as Infer } from 'zod';
+import { ZodType } from 'zod';
+import { GlobalConfig } from '../../config/global';
+import { HOST_DISABLED } from '../../constants/error-messages';
+import { pkg } from '../../expose.cjs';
+import { logger } from '../../logger';
+import { ExternalHostError } from '../../types/errors/external-host-error';
+import * as memCache from '../cache/memory';
+import { hash } from '../hash';
+import { type AsyncResult, Result } from '../result';
+import { isHttpUrl, parseUrl, resolveBaseUrl } from '../url';
+import { parseSingleYaml } from '../yaml';
+import { applyAuthorization, removeAuthorization } from './auth';
+import { fetch, stream } from './got';
+import { applyHostRule, findMatchingRule } from './host-rules';
+
+import { getQueue } from './queue';
+import { getRetryAfter, wrapWithRetry } from './retry-after';
+import { getThrottle } from './throttle';
+import type {
+  GotOptions,
+  GotStreamOptions,
+  GotTask,
+  HttpMethod,
+  HttpOptions,
+  HttpResponse,
+  SafeJsonError,
+} from './types';
+import { copyResponse } from './util';
 
 export interface InternalJsonUnsafeOptions<
   Opts extends HttpOptions = HttpOptions,
@@ -27,3 +56,591 @@ export interface InternalHttpOptions extends HttpOptions {
 
   parseJson?: Options['parseJson'];
 }
+
+export function applyDefaultHeaders(options: Options): void {
+  const renovateVersion = pkg.version;
+  options.headers = {
+    ...options.headers,
+    'user-agent':
+      GlobalConfig.get('userAgent') ??
+      `RenovateBot/${renovateVersion} (https://github.com/renovatebot/renovate)`,
+  };
+}
+
+export abstract class HttpBase<
+  JSONOpts extends HttpOptions = HttpOptions,
+  Opts extends HttpOptions = HttpOptions,
+> {
+  private readonly options: InternalGotOptions;
+
+  protected get baseUrl(): string | undefined {
+    return undefined;
+  }
+
+  constructor(
+    protected hostType: string,
+    options: HttpOptions = {},
+  ) {
+    const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2;
+    this.options = merge<InternalGotOptions>(
+      options,
+      {
+        method: 'get',
+        context: { hostType },
+        retry: {
+          calculateDelay: (retryObject) =>
+            this.calculateRetryDelay(retryObject),
+          limit: retryLimit,
+          maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it
+        },
+      },
+      { isMergeableObject: is.plainObject },
+    );
+  }
+  private async request(
+    requestUrl: string | URL,
+    httpOptions: InternalHttpOptions,
+  ): Promise<HttpResponse<string>>;
+  private async request(
+    requestUrl: string | URL,
+    httpOptions: InternalHttpOptions & { responseType: 'text' },
+  ): Promise<HttpResponse<string>>;
+  private async request(
+    requestUrl: string | URL,
+    httpOptions: InternalHttpOptions & { responseType: 'buffer' },
+  ): Promise<HttpResponse<Buffer>>;
+  private async request<T = unknown>(
+    requestUrl: string | URL,
+    httpOptions: InternalHttpOptions & { responseType: 'json' },
+  ): Promise<HttpResponse<T>>;
+
+  private async request(
+    requestUrl: string | URL,
+    httpOptions: InternalHttpOptions,
+  ): Promise<HttpResponse<unknown>> {
+    const resolvedUrl = this.resolveUrl(requestUrl, httpOptions);
+    const url = resolvedUrl.toString();
+
+    this.processOptions(resolvedUrl, httpOptions);
+
+    let options = merge<InternalGotOptions, InternalHttpOptions>(
+      {
+        ...this.options,
+        hostType: this.hostType,
+      },
+      httpOptions,
+      { isMergeableObject: is.plainObject },
+    );
+
+    logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`);
+
+    options.hooks = {
+      beforeRedirect: [removeAuthorization],
+    };
+
+    applyDefaultHeaders(options);
+
+    if (
+      is.undefined(options.readOnly) &&
+      ['head', 'get'].includes(options.method)
+    ) {
+      options.readOnly = true;
+    }
+
+    const hostRule = findMatchingRule(url, options);
+    options = applyHostRule(url, options, hostRule);
+    if (options.enabled === false) {
+      logger.debug(`Host is disabled - rejecting request. HostUrl: ${url}`);
+      throw new Error(HOST_DISABLED);
+    }
+    options = applyAuthorization(options);
+    options.timeout ??= 60000;
+
+    const { cacheProvider } = options;
+    const cachedResponse = await cacheProvider?.bypassServer<string | Buffer>(
+      url,
+    );
+    if (cachedResponse) {
+      return cachedResponse;
+    }
+
+    const memCacheKey =
+      options.memCache !== false &&
+      (options.method === 'get' || options.method === 'head')
+        ? hash(
+            `got-${JSON.stringify({
+              url,
+              headers: options.headers,
+              method: options.method,
+            })}`,
+          )
+        : null;
+
+    let resPromise: Promise<HttpResponse<unknown>> | null = null;
+
+    // Cache GET requests unless memCache=false
+    if (memCacheKey) {
+      resPromise = memCache.get(memCacheKey);
+    }
+
+    if (!resPromise) {
+      if (cacheProvider) {
+        await cacheProvider.setCacheHeaders(url, options);
+      }
+
+      const startTime = Date.now();
+      const httpTask: GotTask = () => {
+        const queueMs = Date.now() - startTime;
+        return fetch(url, options, { queueMs });
+      };
+
+      const throttle = getThrottle(url);
+      const throttledTask = throttle ? () => throttle.add(httpTask) : httpTask;
+
+      const queue = getQueue(url);
+      const queuedTask = queue ? () => queue.add(throttledTask) : throttledTask;
+
+      const { maxRetryAfter = 60 } = hostRule;
+      resPromise = wrapWithRetry(queuedTask, url, getRetryAfter, maxRetryAfter);
+
+      if (memCacheKey) {
+        memCache.set(memCacheKey, resPromise);
+      }
+    }
+
+    try {
+      const res = await resPromise;
+      const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304;
+      const resCopy = copyResponse(res, deepCopyNeeded);
+      resCopy.authorization = !!options?.headers?.authorization;
+
+      if (cacheProvider) {
+        return await cacheProvider.wrapServerResponse(url, resCopy);
+      }
+
+      return resCopy;
+    } catch (err) {
+      const { abortOnError, abortIgnoreStatusCodes } = options;
+      if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) {
+        throw new ExternalHostError(err);
+      }
+
+      const staleResponse = await cacheProvider?.bypassServer<string | Buffer>(
+        url,
+        true,
+      );
+      if (staleResponse) {
+        logger.debug(
+          { err },
+          `Request error: returning stale cache instead for ${url}`,
+        );
+        return staleResponse;
+      }
+
+      this.handleError(requestUrl, httpOptions, err);
+    }
+  }
+
+  protected processOptions(_url: URL, _options: InternalHttpOptions): void {
+    // noop
+  }
+
+  protected handleError(
+    _url: string | URL,
+    _httpOptions: HttpOptions,
+    err: Error,
+  ): never {
+    throw err;
+  }
+
+  protected resolveUrl(
+    requestUrl: string | URL,
+    options: HttpOptions | undefined,
+  ): URL {
+    let url = requestUrl;
+
+    if (url instanceof URL) {
+      // already a aboslute URL
+      return url;
+    }
+
+    const baseUrl = options?.baseUrl ?? this.baseUrl;
+    if (baseUrl) {
+      url = resolveBaseUrl(baseUrl, url);
+    }
+
+    const parsedUrl = parseUrl(url);
+    if (!parsedUrl || !isHttpUrl(parsedUrl)) {
+      logger.error({ url: requestUrl }, 'Request Error: cannot parse url');
+      throw new Error('Invalid URL');
+    }
+    return parsedUrl;
+  }
+
+  protected calculateRetryDelay({ computedValue }: RetryObject): number {
+    return computedValue;
+  }
+
+  get(
+    url: string,
+    options: HttpOptions = {},
+  ): Promise<HttpResponse<string | Buffer>> {
+    return this.request(url, options);
+  }
+
+  head(url: string, options: HttpOptions = {}): Promise<HttpResponse<never>> {
+    // to complex to validate
+    return this.request(url, {
+      ...options,
+      responseType: 'text',
+      method: 'head',
+    }) as Promise<HttpResponse<never>>;
+  }
+
+  getText(
+    url: string | URL,
+    options: HttpOptions = {},
+  ): Promise<HttpResponse<string>> {
+    return this.request(url, { ...options, responseType: 'text' });
+  }
+
+  getBuffer(
+    url: string | URL,
+    options: HttpOptions = {},
+  ): Promise<HttpResponse<Buffer>> {
+    return this.request(url, { ...options, responseType: 'buffer' });
+  }
+
+  protected requestJsonUnsafe<ResT>(
+    method: HttpMethod,
+    { url, httpOptions: requestOptions }: InternalJsonUnsafeOptions<JSONOpts>,
+  ): Promise<HttpResponse<ResT>> {
+    const { body: json, ...httpOptions } = { ...requestOptions };
+    const opts: InternalHttpOptions = {
+      ...httpOptions,
+      method,
+    };
+    // signal that we expect a json response
+    opts.headers = {
+      accept: 'application/json',
+      ...opts.headers,
+    };
+    if (json) {
+      opts.json = json;
+    }
+    return this.request<ResT>(url, { ...opts, responseType: 'json' });
+  }
+
+  private async requestJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>(
+    method: HttpMethod,
+    options: InternalJsonOptions<JSONOpts, ResT, Schema>,
+  ): Promise<HttpResponse<ResT>> {
+    const res = await this.requestJsonUnsafe<ResT>(method, options);
+
+    if (options.schema) {
+      res.body = await options.schema.parseAsync(res.body);
+    }
+
+    return res;
+  }
+
+  private resolveArgs<ResT = unknown>(
+    arg1: string,
+    arg2: JSONOpts | ZodType<ResT> | undefined,
+    arg3: ZodType<ResT> | undefined,
+  ): InternalJsonOptions<JSONOpts, ResT> {
+    const res: InternalJsonOptions<JSONOpts, ResT> = { url: arg1 };
+
+    if (arg2 instanceof ZodType) {
+      res.schema = arg2;
+    } else if (arg2) {
+      res.httpOptions = arg2;
+    }
+
+    if (arg3) {
+      res.schema = arg3;
+    }
+
+    return res;
+  }
+
+  async getPlain(url: string, options?: Opts): Promise<HttpResponse> {
+    const opt = options ?? {};
+    return await this.getText(url, {
+      headers: {
+        Accept: 'text/plain',
+      },
+      ...opt,
+    });
+  }
+
+  /**
+   * @deprecated use `getYaml` instead
+   */
+  async getYamlUnchecked<ResT>(
+    url: string,
+    options?: Opts,
+  ): Promise<HttpResponse<ResT>> {
+    const res = await this.getText(url, options);
+    const body = parseSingleYaml<ResT>(res.body);
+    return { ...res, body };
+  }
+
+  async getYaml<Schema extends ZodType<any, any, any>>(
+    url: string,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  async getYaml<Schema extends ZodType<any, any, any>>(
+    url: string,
+    options: Opts,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  async getYaml<Schema extends ZodType<any, any, any>>(
+    arg1: string,
+    arg2?: Opts | Schema,
+    arg3?: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>> {
+    const url = arg1;
+    let schema: Schema;
+    let httpOptions: Opts | undefined;
+    if (arg3) {
+      schema = arg3;
+      httpOptions = arg2 as Opts;
+    } else {
+      schema = arg2 as Schema;
+    }
+
+    const opts: InternalHttpOptions = {
+      ...httpOptions,
+      method: 'get',
+    };
+
+    const res = await this.getText(url, opts);
+    const body = await schema.parseAsync(parseSingleYaml(res.body));
+    return { ...res, body };
+  }
+
+  getYamlSafe<
+    ResT extends NonNullable<unknown>,
+    Schema extends ZodType<ResT> = ZodType<ResT>,
+  >(url: string, schema: Schema): AsyncResult<Infer<Schema>, SafeJsonError>;
+  getYamlSafe<
+    ResT extends NonNullable<unknown>,
+    Schema extends ZodType<ResT> = ZodType<ResT>,
+  >(
+    url: string,
+    options: Opts,
+    schema: Schema,
+  ): AsyncResult<Infer<Schema>, SafeJsonError>;
+  getYamlSafe<
+    ResT extends NonNullable<unknown>,
+    Schema extends ZodType<ResT> = ZodType<ResT>,
+  >(
+    arg1: string,
+    arg2: Opts | Schema,
+    arg3?: Schema,
+  ): AsyncResult<ResT, SafeJsonError> {
+    const url = arg1;
+    let schema: Schema;
+    let httpOptions: Opts | undefined;
+    if (arg3) {
+      schema = arg3;
+      httpOptions = arg2 as Opts;
+    } else {
+      schema = arg2 as Schema;
+    }
+
+    let res: AsyncResult<HttpResponse<ResT>, SafeJsonError>;
+    if (httpOptions) {
+      res = Result.wrap(this.getYaml(url, httpOptions, schema));
+    } else {
+      res = Result.wrap(this.getYaml(url, schema));
+    }
+
+    return res.transform((response) => Result.ok(response.body));
+  }
+
+  /**
+   * Request JSON and return the response without any validation.
+   *
+   * The usage of this method is discouraged, please use `getJson` instead.
+   *
+   * If you're new to Zod schema validation library:
+   * - consult the [documentation of Zod library](https://github.com/colinhacks/zod?tab=readme-ov-file#basic-usage)
+   * - search the Renovate codebase for 'zod' module usage
+   * - take a look at the `schema-utils.ts` file for Renovate-specific schemas and utilities
+   */
+  getJsonUnchecked<ResT = unknown>(
+    url: string,
+    options?: JSONOpts,
+  ): Promise<HttpResponse<ResT>> {
+    return this.requestJson<ResT>('get', { url, httpOptions: options });
+  }
+
+  /**
+   * Request JSON with a Zod schema for the response,
+   * throwing an error if the response is not valid.
+   *
+   * @param url
+   * @param schema Zod schema for the response
+   */
+  getJson<Schema extends ZodType<any, any, any>>(
+    url: string,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  getJson<Schema extends ZodType<any, any, any>>(
+    url: string,
+    options: JSONOpts,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  getJson<Schema extends ZodType<any, any, any>>(
+    arg1: string,
+    arg2?: JSONOpts | Schema,
+    arg3?: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>> {
+    const args = this.resolveArgs<Infer<Schema>>(arg1, arg2, arg3);
+    return this.requestJson<Infer<Schema>>('get', args);
+  }
+
+  /**
+   * Request JSON with a Zod schema for the response,
+   * wrapping response data in a `Result` class.
+   *
+   * @param url
+   * @param schema Zod schema for the response
+   */
+  getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
+    url: string,
+    schema: Schema,
+  ): AsyncResult<Infer<Schema>, SafeJsonError>;
+  getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
+    url: string,
+    options: JSONOpts,
+    schema: Schema,
+  ): AsyncResult<Infer<Schema>, SafeJsonError>;
+  getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
+    arg1: string,
+    arg2?: JSONOpts | Schema,
+    arg3?: Schema,
+  ): AsyncResult<ResT, SafeJsonError> {
+    const args = this.resolveArgs<ResT>(arg1, arg2, arg3);
+    return Result.wrap(this.requestJson<ResT>('get', args)).transform(
+      (response) => Result.ok(response.body),
+    );
+  }
+
+  /**
+   * @deprecated use `head` instead
+   */
+  headJson(url: string, httpOptions?: JSONOpts): Promise<HttpResponse<never>> {
+    return this.requestJson<never>('head', { url, httpOptions });
+  }
+
+  postJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
+  postJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  postJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    options: JSONOpts,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  postJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
+    arg1: string,
+    arg2?: JSONOpts | Schema,
+    arg3?: Schema,
+  ): Promise<HttpResponse<T>> {
+    const args = this.resolveArgs(arg1, arg2, arg3);
+    return this.requestJson<T>('post', args);
+  }
+
+  putJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
+  putJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  putJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    options: JSONOpts,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  putJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
+    arg1: string,
+    arg2?: JSONOpts | Schema,
+    arg3?: ZodType,
+  ): Promise<HttpResponse<T>> {
+    const args = this.resolveArgs(arg1, arg2, arg3);
+    return this.requestJson<T>('put', args);
+  }
+
+  patchJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
+  patchJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  patchJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    options: JSONOpts,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  patchJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
+    arg1: string,
+    arg2?: JSONOpts | Schema,
+    arg3?: Schema,
+  ): Promise<HttpResponse<T>> {
+    const args = this.resolveArgs(arg1, arg2, arg3);
+    return this.requestJson<T>('patch', args);
+  }
+
+  deleteJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
+  deleteJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  deleteJson<T, Schema extends ZodType<T> = ZodType<T>>(
+    url: string,
+    options: JSONOpts,
+    schema: Schema,
+  ): Promise<HttpResponse<Infer<Schema>>>;
+  deleteJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
+    arg1: string,
+    arg2?: JSONOpts | Schema,
+    arg3?: Schema,
+  ): Promise<HttpResponse<T>> {
+    const args = this.resolveArgs(arg1, arg2, arg3);
+    return this.requestJson<T>('delete', args);
+  }
+
+  stream(url: string, options?: HttpOptions): NodeJS.ReadableStream {
+    let combinedOptions: Merge<
+      GotStreamOptions,
+      SetRequired<InternalHttpOptions, 'method'>
+    > = {
+      ...this.options,
+      hostType: this.hostType,
+      ...options,
+      method: 'get',
+    };
+
+    const resolvedUrl = this.resolveUrl(url, options).toString();
+
+    applyDefaultHeaders(combinedOptions);
+
+    if (
+      is.undefined(combinedOptions.readOnly) &&
+      ['head', 'get'].includes(combinedOptions.method)
+    ) {
+      combinedOptions.readOnly = true;
+    }
+
+    const hostRule = findMatchingRule(url, combinedOptions);
+    combinedOptions = applyHostRule(resolvedUrl, combinedOptions, hostRule);
+    if (combinedOptions.enabled === false) {
+      throw new Error(HOST_DISABLED);
+    }
+    combinedOptions = applyAuthorization(combinedOptions);
+
+    return stream(resolvedUrl, combinedOptions);
+  }
+}
diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts
index 6cd54196705b703388f95732f0816c929d7c6e0e..ab06e48a38ecc2996144e907c66bf177182647bd 100644
--- a/lib/util/http/index.ts
+++ b/lib/util/http/index.ts
@@ -1,671 +1,9 @@
-// TODO: refactor code to remove this (#9651)
-import './legacy';
+import { EmptyResultError } from './errors';
+import { RequestError } from './got';
+import { HttpBase } from './http';
 
-import is from '@sindresorhus/is';
-import merge from 'deepmerge';
-import type { Options, RetryObject } from 'got';
-import got, { RequestError } from 'got';
-import type { SetRequired } from 'type-fest';
-import type { infer as Infer, ZodError } from 'zod';
-import { ZodType } from 'zod';
-import { GlobalConfig } from '../../config/global';
-import { HOST_DISABLED } from '../../constants/error-messages';
-import { pkg } from '../../expose.cjs';
-import { logger } from '../../logger';
-import { ExternalHostError } from '../../types/errors/external-host-error';
-import * as memCache from '../cache/memory';
-import { hash } from '../hash';
-import { type AsyncResult, Result } from '../result';
-import { type HttpRequestStatsDataPoint, HttpStats } from '../stats';
-import { isHttpUrl, parseUrl, resolveBaseUrl } from '../url';
-import { parseSingleYaml } from '../yaml';
-import { applyAuthorization, removeAuthorization } from './auth';
-import { hooks } from './hooks';
-import { applyHostRule, findMatchingRule } from './host-rules';
-import type {
-  InternalGotOptions,
-  InternalHttpOptions,
-  InternalJsonOptions,
-  InternalJsonUnsafeOptions,
-} from './http';
-import { getQueue } from './queue';
-import { getRetryAfter, wrapWithRetry } from './retry-after';
-import { getThrottle } from './throttle';
-import type {
-  GotBufferOptions,
-  GotOptions,
-  GotTask,
-  HttpMethod,
-  HttpOptions,
-  HttpResponse,
-} from './types';
-import { copyResponse } from './util';
+export { RequestError as HttpError, EmptyResultError };
 
-export { RequestError as HttpError };
+export type * from './types';
 
-export class EmptyResultError extends Error {}
-export type SafeJsonError = RequestError | ZodError | EmptyResultError;
-
-function applyDefaultHeaders(options: Options): void {
-  const renovateVersion = pkg.version;
-  options.headers = {
-    ...options.headers,
-    'user-agent':
-      GlobalConfig.get('userAgent') ??
-      `RenovateBot/${renovateVersion} (https://github.com/renovatebot/renovate)`,
-  };
-}
-
-type QueueStatsData = Pick<HttpRequestStatsDataPoint, 'queueMs'>;
-
-async function gotTask(
-  url: string,
-  options: SetRequired<GotOptions, 'method'>,
-  queueStats: QueueStatsData,
-): Promise<HttpResponse<unknown>> {
-  logger.trace({ url, options }, 'got request');
-
-  let duration = 0;
-  let statusCode = 0;
-  try {
-    // Cheat the TS compiler using `as` to pick a specific overload.
-    // Otherwise it doesn't typecheck.
-    const resp = await got(url, { ...options, hooks } as GotBufferOptions);
-    statusCode = resp.statusCode;
-    duration =
-      resp.timings.phases.total ?? /* v8 ignore next: can't be tested */ 0;
-    return resp;
-  } catch (error) {
-    if (error instanceof RequestError) {
-      statusCode = error.response?.statusCode ?? -1;
-      duration =
-        error.timings?.phases.total ?? /* v8 ignore next: can't be tested */ -1;
-      const method = options.method.toUpperCase();
-      const code = error.code ?? /* v8 ignore next */ 'UNKNOWN';
-      const retryCount = error.request?.retryCount ?? /* v8 ignore next */ -1;
-      logger.debug(
-        `${method} ${url} = (code=${code}, statusCode=${statusCode} retryCount=${retryCount}, duration=${duration})`,
-      );
-    }
-
-    throw error;
-    /* v8 ignore next: 🐛 https://github.com/bcoe/c8/issues/229 */
-  } finally {
-    HttpStats.write({
-      method: options.method,
-      url,
-      reqMs: duration,
-      queueMs: queueStats.queueMs,
-      status: statusCode,
-    });
-  }
-}
-
-export class Http<Opts extends HttpOptions = HttpOptions> {
-  private readonly options: InternalGotOptions;
-
-  protected get baseUrl(): string | undefined {
-    return undefined;
-  }
-
-  constructor(
-    protected hostType: string,
-    options: HttpOptions = {},
-  ) {
-    const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2;
-    this.options = merge<InternalGotOptions>(
-      options,
-      {
-        method: 'get',
-        context: { hostType },
-        retry: {
-          calculateDelay: (retryObject) =>
-            this.calculateRetryDelay(retryObject),
-          limit: retryLimit,
-          maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it
-        },
-      },
-      { isMergeableObject: is.plainObject },
-    );
-  }
-  private async request(
-    requestUrl: string | URL,
-    httpOptions: InternalHttpOptions,
-  ): Promise<HttpResponse<string>>;
-  private async request(
-    requestUrl: string | URL,
-    httpOptions: InternalHttpOptions & { responseType: 'text' },
-  ): Promise<HttpResponse<string>>;
-  private async request(
-    requestUrl: string | URL,
-    httpOptions: InternalHttpOptions & { responseType: 'buffer' },
-  ): Promise<HttpResponse<Buffer>>;
-  private async request<T = unknown>(
-    requestUrl: string | URL,
-    httpOptions: InternalHttpOptions & { responseType: 'json' },
-  ): Promise<HttpResponse<T>>;
-
-  private async request(
-    requestUrl: string | URL,
-    httpOptions: InternalHttpOptions,
-  ): Promise<HttpResponse<unknown>> {
-    const resolvedUrl = this.resolveUrl(requestUrl, httpOptions);
-    const url = resolvedUrl.toString();
-
-    this.processOptions(resolvedUrl, httpOptions);
-
-    let options = merge<InternalGotOptions, InternalHttpOptions>(
-      {
-        ...this.options,
-        hostType: this.hostType,
-      },
-      httpOptions,
-      { isMergeableObject: is.plainObject },
-    );
-
-    logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`);
-
-    options.hooks = {
-      beforeRedirect: [removeAuthorization],
-    };
-
-    applyDefaultHeaders(options);
-
-    if (
-      is.undefined(options.readOnly) &&
-      ['head', 'get'].includes(options.method)
-    ) {
-      options.readOnly = true;
-    }
-
-    const hostRule = findMatchingRule(url, options);
-    options = applyHostRule(url, options, hostRule);
-    if (options.enabled === false) {
-      logger.debug(`Host is disabled - rejecting request. HostUrl: ${url}`);
-      throw new Error(HOST_DISABLED);
-    }
-    options = applyAuthorization(options);
-    options.timeout ??= 60000;
-
-    const { cacheProvider } = options;
-    const cachedResponse = await cacheProvider?.bypassServer<string | Buffer>(
-      url,
-    );
-    if (cachedResponse) {
-      return cachedResponse;
-    }
-
-    const memCacheKey =
-      options.memCache !== false &&
-      (options.method === 'get' || options.method === 'head')
-        ? hash(
-            `got-${JSON.stringify({
-              url,
-              headers: options.headers,
-              method: options.method,
-            })}`,
-          )
-        : null;
-
-    let resPromise: Promise<HttpResponse<unknown>> | null = null;
-
-    // Cache GET requests unless memCache=false
-    if (memCacheKey) {
-      resPromise = memCache.get(memCacheKey);
-    }
-
-    if (!resPromise) {
-      if (cacheProvider) {
-        await cacheProvider.setCacheHeaders(url, options);
-      }
-
-      const startTime = Date.now();
-      const httpTask: GotTask = () => {
-        const queueMs = Date.now() - startTime;
-        return gotTask(url, options, { queueMs });
-      };
-
-      const throttle = getThrottle(url);
-      const throttledTask = throttle ? () => throttle.add(httpTask) : httpTask;
-
-      const queue = getQueue(url);
-      const queuedTask = queue ? () => queue.add(throttledTask) : throttledTask;
-
-      const { maxRetryAfter = 60 } = hostRule;
-      resPromise = wrapWithRetry(queuedTask, url, getRetryAfter, maxRetryAfter);
-
-      if (memCacheKey) {
-        memCache.set(memCacheKey, resPromise);
-      }
-    }
-
-    try {
-      const res = await resPromise;
-      const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304;
-      const resCopy = copyResponse(res, deepCopyNeeded);
-      resCopy.authorization = !!options?.headers?.authorization;
-
-      if (cacheProvider) {
-        return await cacheProvider.wrapServerResponse(url, resCopy);
-      }
-
-      return resCopy;
-    } catch (err) {
-      const { abortOnError, abortIgnoreStatusCodes } = options;
-      if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) {
-        throw new ExternalHostError(err);
-      }
-
-      const staleResponse = await cacheProvider?.bypassServer<string | Buffer>(
-        url,
-        true,
-      );
-      if (staleResponse) {
-        logger.debug(
-          { err },
-          `Request error: returning stale cache instead for ${url}`,
-        );
-        return staleResponse;
-      }
-
-      this.handleError(requestUrl, httpOptions, err);
-    }
-  }
-
-  protected processOptions(_url: URL, _options: InternalHttpOptions): void {
-    // noop
-  }
-
-  protected handleError(
-    _url: string | URL,
-    _httpOptions: HttpOptions,
-    err: Error,
-  ): never {
-    throw err;
-  }
-
-  protected resolveUrl(
-    requestUrl: string | URL,
-    options: HttpOptions | undefined,
-  ): URL {
-    let url = requestUrl;
-
-    if (url instanceof URL) {
-      // already a aboslute URL
-      return url;
-    }
-
-    const baseUrl = options?.baseUrl ?? this.baseUrl;
-    if (baseUrl) {
-      url = resolveBaseUrl(baseUrl, url);
-    }
-
-    const parsedUrl = parseUrl(url);
-    if (!parsedUrl || !isHttpUrl(parsedUrl)) {
-      logger.error({ url: requestUrl }, 'Request Error: cannot parse url');
-      throw new Error('Invalid URL');
-    }
-    return parsedUrl;
-  }
-
-  protected calculateRetryDelay({ computedValue }: RetryObject): number {
-    return computedValue;
-  }
-
-  get(
-    url: string,
-    options: HttpOptions = {},
-  ): Promise<HttpResponse<string | Buffer>> {
-    return this.request(url, options);
-  }
-
-  head(url: string, options: HttpOptions = {}): Promise<HttpResponse<never>> {
-    // to complex to validate
-    return this.request(url, {
-      ...options,
-      responseType: 'text',
-      method: 'head',
-    }) as Promise<HttpResponse<never>>;
-  }
-
-  getText(
-    url: string | URL,
-    options: HttpOptions = {},
-  ): Promise<HttpResponse<string>> {
-    return this.request(url, { ...options, responseType: 'text' });
-  }
-
-  getBuffer(
-    url: string | URL,
-    options: HttpOptions = {},
-  ): Promise<HttpResponse<Buffer>> {
-    return this.request(url, { ...options, responseType: 'buffer' });
-  }
-
-  protected requestJsonUnsafe<ResT>(
-    method: HttpMethod,
-    { url, httpOptions: requestOptions }: InternalJsonUnsafeOptions<Opts>,
-  ): Promise<HttpResponse<ResT>> {
-    const { body: json, ...httpOptions } = { ...requestOptions };
-    const opts: InternalHttpOptions = {
-      ...httpOptions,
-      method,
-    };
-    // signal that we expect a json response
-    opts.headers = {
-      accept: 'application/json',
-      ...opts.headers,
-    };
-    if (json) {
-      opts.json = json;
-    }
-    return this.request<ResT>(url, { ...opts, responseType: 'json' });
-  }
-
-  private async requestJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>(
-    method: HttpMethod,
-    options: InternalJsonOptions<Opts, ResT, Schema>,
-  ): Promise<HttpResponse<ResT>> {
-    const res = await this.requestJsonUnsafe<ResT>(method, options);
-
-    if (options.schema) {
-      res.body = await options.schema.parseAsync(res.body);
-    }
-
-    return res;
-  }
-
-  private resolveArgs<ResT = unknown>(
-    arg1: string,
-    arg2: Opts | ZodType<ResT> | undefined,
-    arg3: ZodType<ResT> | undefined,
-  ): InternalJsonOptions<Opts, ResT> {
-    const res: InternalJsonOptions<Opts, ResT> = { url: arg1 };
-
-    if (arg2 instanceof ZodType) {
-      res.schema = arg2;
-    } else if (arg2) {
-      res.httpOptions = arg2;
-    }
-
-    if (arg3) {
-      res.schema = arg3;
-    }
-
-    return res;
-  }
-
-  async getPlain(url: string, options?: Opts): Promise<HttpResponse> {
-    const opt = options ?? {};
-    return await this.getText(url, {
-      headers: {
-        Accept: 'text/plain',
-      },
-      ...opt,
-    });
-  }
-
-  /**
-   * @deprecated use `getYaml` instead
-   */
-  async getYamlUnchecked<ResT>(
-    url: string,
-    options?: Opts,
-  ): Promise<HttpResponse<ResT>> {
-    const res = await this.getText(url, options);
-    const body = parseSingleYaml<ResT>(res.body);
-    return { ...res, body };
-  }
-
-  async getYaml<Schema extends ZodType<any, any, any>>(
-    url: string,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  async getYaml<Schema extends ZodType<any, any, any>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  async getYaml<Schema extends ZodType<any, any, any>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>> {
-    const url = arg1;
-    let schema: Schema;
-    let httpOptions: Opts | undefined;
-    if (arg3) {
-      schema = arg3;
-      httpOptions = arg2 as Opts;
-    } else {
-      schema = arg2 as Schema;
-    }
-
-    const opts: InternalHttpOptions = {
-      ...httpOptions,
-      method: 'get',
-    };
-
-    const res = await this.getText(url, opts);
-    const body = await schema.parseAsync(parseSingleYaml(res.body));
-    return { ...res, body };
-  }
-
-  getYamlSafe<
-    ResT extends NonNullable<unknown>,
-    Schema extends ZodType<ResT> = ZodType<ResT>,
-  >(url: string, schema: Schema): AsyncResult<Infer<Schema>, SafeJsonError>;
-  getYamlSafe<
-    ResT extends NonNullable<unknown>,
-    Schema extends ZodType<ResT> = ZodType<ResT>,
-  >(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): AsyncResult<Infer<Schema>, SafeJsonError>;
-  getYamlSafe<
-    ResT extends NonNullable<unknown>,
-    Schema extends ZodType<ResT> = ZodType<ResT>,
-  >(
-    arg1: string,
-    arg2: Opts | Schema,
-    arg3?: Schema,
-  ): AsyncResult<ResT, SafeJsonError> {
-    const url = arg1;
-    let schema: Schema;
-    let httpOptions: Opts | undefined;
-    if (arg3) {
-      schema = arg3;
-      httpOptions = arg2 as Opts;
-    } else {
-      schema = arg2 as Schema;
-    }
-
-    let res: AsyncResult<HttpResponse<ResT>, SafeJsonError>;
-    if (httpOptions) {
-      res = Result.wrap(this.getYaml(url, httpOptions, schema));
-    } else {
-      res = Result.wrap(this.getYaml(url, schema));
-    }
-
-    return res.transform((response) => Result.ok(response.body));
-  }
-
-  /**
-   * Request JSON and return the response without any validation.
-   *
-   * The usage of this method is discouraged, please use `getJson` instead.
-   *
-   * If you're new to Zod schema validation library:
-   * - consult the [documentation of Zod library](https://github.com/colinhacks/zod?tab=readme-ov-file#basic-usage)
-   * - search the Renovate codebase for 'zod' module usage
-   * - take a look at the `schema-utils.ts` file for Renovate-specific schemas and utilities
-   */
-  getJsonUnchecked<ResT = unknown>(
-    url: string,
-    options?: Opts,
-  ): Promise<HttpResponse<ResT>> {
-    return this.requestJson<ResT>('get', { url, httpOptions: options });
-  }
-
-  /**
-   * Request JSON with a Zod schema for the response,
-   * throwing an error if the response is not valid.
-   *
-   * @param url
-   * @param schema Zod schema for the response
-   */
-  getJson<Schema extends ZodType<any, any, any>>(
-    url: string,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  getJson<Schema extends ZodType<any, any, any>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  getJson<Schema extends ZodType<any, any, any>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>> {
-    const args = this.resolveArgs<Infer<Schema>>(arg1, arg2, arg3);
-    return this.requestJson<Infer<Schema>>('get', args);
-  }
-
-  /**
-   * Request JSON with a Zod schema for the response,
-   * wrapping response data in a `Result` class.
-   *
-   * @param url
-   * @param schema Zod schema for the response
-   */
-  getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
-    url: string,
-    schema: Schema,
-  ): AsyncResult<Infer<Schema>, SafeJsonError>;
-  getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): AsyncResult<Infer<Schema>, SafeJsonError>;
-  getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: Schema,
-  ): AsyncResult<ResT, SafeJsonError> {
-    const args = this.resolveArgs<ResT>(arg1, arg2, arg3);
-    return Result.wrap(this.requestJson<ResT>('get', args)).transform(
-      (response) => Result.ok(response.body),
-    );
-  }
-
-  headJson(url: string, httpOptions?: Opts): Promise<HttpResponse<never>> {
-    return this.requestJson<never>('head', { url, httpOptions });
-  }
-
-  postJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>;
-  postJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  postJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  postJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: Schema,
-  ): Promise<HttpResponse<T>> {
-    const args = this.resolveArgs(arg1, arg2, arg3);
-    return this.requestJson<T>('post', args);
-  }
-
-  putJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>;
-  putJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  putJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  putJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: ZodType,
-  ): Promise<HttpResponse<T>> {
-    const args = this.resolveArgs(arg1, arg2, arg3);
-    return this.requestJson<T>('put', args);
-  }
-
-  patchJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>;
-  patchJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  patchJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  patchJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: Schema,
-  ): Promise<HttpResponse<T>> {
-    const args = this.resolveArgs(arg1, arg2, arg3);
-    return this.requestJson<T>('patch', args);
-  }
-
-  deleteJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>;
-  deleteJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  deleteJson<T, Schema extends ZodType<T> = ZodType<T>>(
-    url: string,
-    options: Opts,
-    schema: Schema,
-  ): Promise<HttpResponse<Infer<Schema>>>;
-  deleteJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
-    arg1: string,
-    arg2?: Opts | Schema,
-    arg3?: Schema,
-  ): Promise<HttpResponse<T>> {
-    const args = this.resolveArgs(arg1, arg2, arg3);
-    return this.requestJson<T>('delete', args);
-  }
-
-  stream(url: string, options?: HttpOptions): NodeJS.ReadableStream {
-    // TODO: fix types (#22198)
-    let combinedOptions: any = {
-      ...this.options,
-      hostType: this.hostType,
-      ...options,
-    };
-
-    const resolvedUrl = this.resolveUrl(url, options).toString();
-
-    applyDefaultHeaders(combinedOptions);
-
-    if (
-      is.undefined(combinedOptions.readOnly) &&
-      ['head', 'get'].includes(combinedOptions.method)
-    ) {
-      combinedOptions.readOnly = true;
-    }
-
-    const hostRule = findMatchingRule(url, combinedOptions);
-    combinedOptions = applyHostRule(resolvedUrl, combinedOptions, hostRule);
-    if (combinedOptions.enabled === false) {
-      throw new Error(HOST_DISABLED);
-    }
-    combinedOptions = applyAuthorization(combinedOptions);
-
-    return got.stream(resolvedUrl, combinedOptions);
-  }
-}
+export class Http extends HttpBase {}
diff --git a/lib/util/http/jira.ts b/lib/util/http/jira.ts
index adfe8aa3d0322e12db5bc87e587e1023f6796d2f..4895540ad4d0674bf83ee9500845eee51d7ce398 100644
--- a/lib/util/http/jira.ts
+++ b/lib/util/http/jira.ts
@@ -1,5 +1,5 @@
+import { HttpBase } from './http';
 import type { HttpOptions } from './types';
-import { Http } from '.';
 
 let baseUrl: string;
 
@@ -7,7 +7,7 @@ export function setBaseUrl(url: string): void {
   baseUrl = url;
 }
 
-export class JiraHttp extends Http {
+export class JiraHttp extends HttpBase {
   protected override get baseUrl(): string | undefined {
     return baseUrl;
   }
diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts
index 2b6a31b4e574a95c15e47cc48fb05229a9a3d43c..ec673701b26df3ce4017bf0b7eab0dab3a9d0706 100644
--- a/lib/util/http/types.ts
+++ b/lib/util/http/types.ts
@@ -1,10 +1,14 @@
 import type { IncomingHttpHeaders } from 'node:http';
 import type {
+  Options,
   OptionsOfBufferResponseBody,
   OptionsOfJSONResponseBody,
   OptionsOfTextResponseBody,
+  RequestError,
 } from 'got';
+import type { ZodError } from 'zod';
 import type { HttpCacheProvider } from './cache/types';
+import type { EmptyResultError } from './errors';
 
 export type GotContextOptions = {
   authType?: string;
@@ -16,6 +20,8 @@ export type GotBufferOptions = OptionsOfBufferResponseBody & GotExtraOptions;
 export type GotTextOptions = OptionsOfTextResponseBody & GotExtraOptions;
 export type GotJSONOptions = OptionsOfJSONResponseBody & GotExtraOptions;
 
+export type GotStreamOptions = Options & GotExtraOptions;
+
 export interface GotExtraOptions {
   abortOnError?: boolean;
   abortIgnoreStatusCodes?: number[];
@@ -27,14 +33,6 @@ export interface GotExtraOptions {
   context?: GotContextOptions;
 }
 
-export interface RequestStats {
-  method: string;
-  url: string;
-  duration: number;
-  queueDuration: number;
-  statusCode: number;
-}
-
 export type OutgoingHttpHeaders = Record<string, string | string[] | undefined>;
 
 export type GraphqlVariables = Record<string, unknown>;
@@ -95,3 +93,5 @@ export interface ConcurrencyLimitRule {
   matchHost: string;
   concurrency: number;
 }
+
+export type SafeJsonError = RequestError | ZodError | EmptyResultError;
diff --git a/vitest.config.ts b/vitest.config.ts
index 05bacaccd0179176fdc7098c84370693d2b6059a..237d98eca691bb86feb35473227ebf89e25ae340 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -109,7 +109,6 @@ export default defineConfig(() =>
             'lib/modules/datasource/hex/v2/package.ts',
             'lib/modules/datasource/hex/v2/signed.ts',
             'lib/util/cache/package/redis.ts',
-            'lib/util/http/http.ts', // TODO: remove when code is moved from index
             'lib/util/http/legacy.ts',
             'lib/workers/repository/cache.ts',
           ],