diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 34b8621470a5fb787046ed6e8a9440a55e5d605e..8102ed15bc53b1a5658c4b0ab564039782974072 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -1768,6 +1768,26 @@ Example config:
 }
 ```
 
+### maxRetryAfter
+
+A remote host may return a `4xx` response with a `Retry-After` header value, which indicates that Renovate has been rate-limited.
+Renovate may try to contact the host again after waiting a certain time, that's set by the host.
+By default, Renovate tries again after the `Retry-After` header value has passed, up to a maximum of 60 seconds.
+If the `Retry-After` value is more than 60 seconds, Renovate will abort the request instead of waiting.
+
+You can configure a different maximum value in seconds using `maxRetryAfter`:
+
+```json
+{
+  "hostRules": [
+    {
+      "matchHost": "api.github.com",
+      "maxRetryAfter": 25
+    }
+  ]
+}
+```
+
 ### dnsCache
 
 Enable got [dnsCache](https://github.com/sindresorhus/got/blob/v11.5.2/readme.md#dnsCache) support.
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index bab96cece2086c4285d443763f84948d980b71fa..c057b68f9b86661f4c099600f6d649c6c3b9648c 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -2741,6 +2741,17 @@ const options: RenovateOptions[] = [
     globalOnly: true,
     default: [],
   },
+  {
+    name: 'maxRetryAfter',
+    description:
+      'Maximum retry-after header value to wait for before retrying a failed request.',
+    type: 'integer',
+    default: 60,
+    stage: 'package',
+    parent: 'hostRules',
+    cli: false,
+    env: false,
+  },
 ];
 
 export function getOptions(): RenovateOptions[] {
diff --git a/lib/types/host-rules.ts b/lib/types/host-rules.ts
index c8b26a1358da5861864e8e7745405d0d68be2007..e29576f4bd655ff121ea640aae8d9044f30352cf 100644
--- a/lib/types/host-rules.ts
+++ b/lib/types/host-rules.ts
@@ -11,6 +11,7 @@ export interface HostRuleSearchResult {
   enableHttp2?: boolean;
   concurrentRequestLimit?: number;
   maxRequestsPerSecond?: number;
+  maxRetryAfter?: number;
 
   dnsCache?: boolean;
   keepAlive?: boolean;
diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts
index 7c3164438e979328c0d3aeb3c832ee8e67eaf8f7..c675e378f5692e1d15ce6e8eb4b3cd6fafb95548 100644
--- a/lib/util/http/index.ts
+++ b/lib/util/http/index.ts
@@ -15,6 +15,7 @@ import { applyAuthorization, removeAuthorization } from './auth';
 import { hooks } from './hooks';
 import { applyHostRule, findMatchingRule } from './host-rules';
 import { getQueue } from './queue';
+import { getRetryAfter, wrapWithRetry } from './retry-after';
 import { Throttle, getThrottle } from './throttle';
 import type {
   GotJSONOptions,
@@ -130,11 +131,14 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
     protected hostType: string,
     options: HttpOptions = {},
   ) {
-    this.options = merge<GotOptions>(options, { context: { hostType } });
-
-    if (process.env.NODE_ENV === 'test') {
-      this.options.retry = 0;
-    }
+    const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2;
+    this.options = merge<GotOptions>(options, {
+      context: { hostType },
+      retry: {
+        limit: retryLimit,
+        maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it
+      },
+    });
   }
 
   protected getThrottle(url: string): Throttle | null {
@@ -226,7 +230,8 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
         ? () => queue.add<HttpResponse<T>>(throttledTask)
         : throttledTask;
 
-      resPromise = queuedTask();
+      const { maxRetryAfter = 60 } = hostRule;
+      resPromise = wrapWithRetry(queuedTask, url, getRetryAfter, maxRetryAfter);
 
       if (memCacheKey) {
         memCache.set(memCacheKey, resPromise);
diff --git a/lib/util/http/retry-after.spec.ts b/lib/util/http/retry-after.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9535cfabb3328d079ebf2935fd8b87ccb719ddac
--- /dev/null
+++ b/lib/util/http/retry-after.spec.ts
@@ -0,0 +1,162 @@
+import { RequestError } from 'got';
+import { getRetryAfter, wrapWithRetry } from './retry-after';
+
+function requestError(
+  response: {
+    statusCode?: number;
+    headers?: Record<string, string | string[]>;
+  } | null = null,
+) {
+  const err = new RequestError('request error', {}, null as never);
+  if (response) {
+    (err as any).response = response;
+  }
+  return err;
+}
+
+describe('util/http/retry-after', () => {
+  beforeEach(() => {
+    jest.useFakeTimers();
+  });
+
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  describe('wrapWithRetry', () => {
+    it('works', async () => {
+      const task = jest.fn(() => Promise.resolve(42));
+      const res = await wrapWithRetry(task, 'foobar', () => null, 60);
+      expect(res).toBe(42);
+      expect(task).toHaveBeenCalledTimes(1);
+    });
+
+    it('throws', async () => {
+      const task = jest.fn(() => Promise.reject(new Error('error')));
+
+      await expect(
+        wrapWithRetry(task, 'http://example.com', () => null, 60),
+      ).rejects.toThrow('error');
+
+      expect(task).toHaveBeenCalledTimes(1);
+    });
+
+    it('retries', async () => {
+      const task = jest
+        .fn()
+        .mockRejectedValueOnce(new Error('error-1'))
+        .mockRejectedValueOnce(new Error('error-2'))
+        .mockResolvedValueOnce(42);
+
+      const p = wrapWithRetry(task, 'http://example.com', () => 1, 60);
+      await jest.advanceTimersByTimeAsync(2000);
+
+      const res = await p;
+      expect(res).toBe(42);
+      expect(task).toHaveBeenCalledTimes(3);
+    });
+
+    it('gives up after max retries', async () => {
+      const task = jest
+        .fn()
+        .mockRejectedValueOnce('error-1')
+        .mockRejectedValueOnce('error-2')
+        .mockRejectedValueOnce('error-3')
+        .mockRejectedValue('error-4');
+
+      const p = wrapWithRetry(task, 'http://example.com', () => 1, 60).catch(
+        (err) => err,
+      );
+      await jest.advanceTimersByTimeAsync(2000);
+
+      await expect(p).resolves.toBe('error-3');
+      expect(task).toHaveBeenCalledTimes(3);
+    });
+
+    it('gives up when delay exceeds maxRetryAfter', async () => {
+      const task = jest.fn().mockRejectedValue('error');
+
+      const p = wrapWithRetry(task, 'http://example.com', () => 61, 60).catch(
+        (err) => err,
+      );
+
+      await expect(p).resolves.toBe('error');
+      expect(task).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('getRetryAfter', () => {
+    it('returns null for non-RequestError', () => {
+      expect(getRetryAfter(new Error())).toBeNull();
+    });
+
+    it('returns null for RequestError without response', () => {
+      expect(getRetryAfter(requestError())).toBeNull();
+    });
+
+    it('returns null for status other than 429', () => {
+      const err = new RequestError('request-error', {}, null as never);
+      (err as any).response = { statusCode: 302 };
+      expect(getRetryAfter(requestError({ statusCode: 302 }))).toBeNull();
+    });
+
+    it('returns null missing "retry-after" header', () => {
+      expect(
+        getRetryAfter(requestError({ statusCode: 429, headers: {} })),
+      ).toBeNull();
+    });
+
+    it('returns null for non-integer "retry-after" header', () => {
+      expect(
+        getRetryAfter(
+          requestError({
+            statusCode: 429,
+            headers: {
+              'retry-after': 'Wed, 21 Oct 2015 07:28:00 GMT',
+            },
+          }),
+        ),
+      ).toBeNull();
+    });
+
+    it('returns delay in seconds from date', () => {
+      jest.setSystemTime(new Date('2020-01-01T00:00:00Z'));
+      expect(
+        getRetryAfter(
+          requestError({
+            statusCode: 429,
+            headers: {
+              'retry-after': 'Wed, 01 Jan 2020 00:00:42 GMT',
+            },
+          }),
+        ),
+      ).toBe(42);
+    });
+
+    it('returns delay in seconds from number', () => {
+      expect(
+        getRetryAfter(
+          requestError({
+            statusCode: 429,
+            headers: {
+              'retry-after': '42',
+            },
+          }),
+        ),
+      ).toBe(42);
+    });
+
+    it('returns null for invalid header value', () => {
+      expect(
+        getRetryAfter(
+          requestError({
+            statusCode: 429,
+            headers: {
+              'retry-after': 'invalid',
+            },
+          }),
+        ),
+      ).toBeNull();
+    });
+  });
+});
diff --git a/lib/util/http/retry-after.ts b/lib/util/http/retry-after.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6cde4ae33a1d852e6d7e2e8194af35a85dfb492a
--- /dev/null
+++ b/lib/util/http/retry-after.ts
@@ -0,0 +1,115 @@
+import { setTimeout } from 'timers/promises';
+import { RequestError } from 'got';
+import { DateTime } from 'luxon';
+import { logger } from '../../logger';
+import { parseUrl } from '../url';
+import type { Task } from './types';
+
+const hostDelays = new Map<string, Promise<unknown>>();
+
+const maxRetries = 2;
+
+/**
+ * Given a task that returns a promise, retry the task if it fails with a
+ * 429 Too Many Requests or 403 Forbidden response, using the Retry-After
+ * header to determine the delay.
+ *
+ * For response codes other than 429 or 403, or if the Retry-After header
+ * is not present or invalid, the task is not retried, throwing the error.
+ */
+export async function wrapWithRetry<T>(
+  task: Task<T>,
+  url: string,
+  getRetryAfter: (err: unknown) => number | null,
+  maxRetryAfter: number,
+): Promise<T> {
+  const key = parseUrl(url)?.host ?? url;
+
+  let retries = 0;
+  for (;;) {
+    try {
+      await hostDelays.get(key);
+      hostDelays.delete(key);
+
+      return await task();
+    } catch (err) {
+      const delaySeconds = getRetryAfter(err);
+      if (delaySeconds === null) {
+        throw err;
+      }
+
+      if (retries === maxRetries) {
+        logger.debug(
+          `Retry-After: reached maximum retries (${maxRetries}) for ${url}`,
+        );
+        throw err;
+      }
+
+      if (delaySeconds > maxRetryAfter) {
+        logger.debug(
+          `Retry-After: delay ${delaySeconds} seconds exceeds maxRetryAfter ${maxRetryAfter} seconds for ${url}`,
+        );
+        throw err;
+      }
+
+      logger.debug(
+        `Retry-After: will retry ${url} after ${delaySeconds} seconds`,
+      );
+
+      const delay = Promise.all([
+        hostDelays.get(key),
+        setTimeout(1000 * delaySeconds),
+      ]);
+      hostDelays.set(key, delay);
+      retries += 1;
+    }
+  }
+}
+
+export function getRetryAfter(err: unknown): number | null {
+  if (!(err instanceof RequestError)) {
+    return null;
+  }
+
+  if (!err.response) {
+    return null;
+  }
+
+  if (err.response.statusCode < 400 || err.response.statusCode >= 500) {
+    logger.warn(
+      { url: err.response.url },
+      `Retry-After: unexpected status code ${err.response.statusCode}`,
+    );
+    return null;
+  }
+
+  const retryAfter = err.response.headers['retry-after']?.trim();
+  if (!retryAfter) {
+    return null;
+  }
+
+  const date = DateTime.fromHTTP(retryAfter);
+  if (date.isValid) {
+    const seconds = Math.floor(date.diffNow('seconds').seconds);
+    if (seconds < 0) {
+      logger.debug(
+        { url: err.response.url, retryAfter },
+        'Retry-After: date in the past',
+      );
+      return null;
+    }
+
+    return seconds;
+  }
+
+  const seconds = parseInt(retryAfter, 10);
+  if (!Number.isNaN(seconds) && seconds > 0) {
+    return seconds;
+  }
+
+  logger.debug(
+    { url: err.response.url, retryAfter },
+    'Retry-After: unsupported format',
+  );
+  return null;
+}
diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts
index 4f576ef17a17885ceb8dc6e72206fe1bb62db006..86b5366ac53b647343ad5faf178b779c1aab7075 100644
--- a/lib/util/http/types.ts
+++ b/lib/util/http/types.ts
@@ -92,4 +92,5 @@ export interface HttpResponse<T = string> {
   authorization?: boolean;
 }
 
-export type GotTask<T> = () => Promise<HttpResponse<T>>;
+export type Task<T> = () => Promise<T>;
+export type GotTask<T> = Task<HttpResponse<T>>;