From acf4bd70fb38b16fd2dff7790f2f3c2122e0251a Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi Date: Wed, 14 Jan 2026 17:01:59 +0000 Subject: [PATCH 1/7] Add handling for 429s from cache service --- .../src/internal/shared/cacheTwirpClient.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cache/src/internal/shared/cacheTwirpClient.ts b/packages/cache/src/internal/shared/cacheTwirpClient.ts index f6c2af61..ade1b8c9 100644 --- a/packages/cache/src/internal/shared/cacheTwirpClient.ts +++ b/packages/cache/src/internal/shared/cacheTwirpClient.ts @@ -1,4 +1,4 @@ -import {info, debug} from '@actions/core' +import {info, debug, warning} from '@actions/core' import {getUserAgentString} from './user-agent' import {NetworkError, UsageError} from './errors' import {getCacheServiceURL} from '../config' @@ -109,6 +109,20 @@ class CacheServiceClient implements Rpc { errorMessage = `${errorMessage}: ${body.msg}` } + + // Handle rate limiting - don't retry, just warn and exit + if (statusCode === HttpCodes.TooManyRequests) { + const retryAfterHeader = response.message.headers['retry-after'] + if (retryAfterHeader) { + const parsedSeconds = parseInt(retryAfterHeader, 10) + if (!isNaN(parsedSeconds) && parsedSeconds > 0) { + warning( + `You've hit a rate limit, your rate limit will reset in ${parsedSeconds} seconds` + ) + } + } + throw new Error(`Rate limited: ${errorMessage}`) + } } catch (error) { if (error instanceof SyntaxError) { debug(`Raw Body: ${rawBody}`) @@ -162,8 +176,7 @@ class CacheServiceClient implements Rpc { HttpCodes.BadGateway, HttpCodes.GatewayTimeout, HttpCodes.InternalServerError, - HttpCodes.ServiceUnavailable, - HttpCodes.TooManyRequests + HttpCodes.ServiceUnavailable ] return retryableStatusCodes.includes(statusCode) From a68693e20ab34b8fd3acf1f0aac6f50bcd8970b3 Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi Date: Fri, 16 Jan 2026 09:44:57 +0000 Subject: [PATCH 2/7] tests & releases --- packages/cache/RELEASES.md | 4 + .../cache/__tests__/cacheTwirpClient.test.ts | 174 ++++++++++++++++++ .../src/internal/shared/cacheTwirpClient.ts | 5 + 3 files changed, 183 insertions(+) create mode 100644 packages/cache/__tests__/cacheTwirpClient.test.ts diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md index 08d484e0..140f17db 100644 --- a/packages/cache/RELEASES.md +++ b/packages/cache/RELEASES.md @@ -1,5 +1,9 @@ # @actions/cache Releases +### 5.0.2 + +Fail cache saves on rate limit errors from the cache service to prevent slowing down impacted runs + ### 5.0.1 - Fix Node.js 24 punycode deprecation warning by updating `@azure/storage-blob` from `^12.13.0` to `^12.29.1` [#2213](https://github.com/actions/toolkit/pull/2213) diff --git a/packages/cache/__tests__/cacheTwirpClient.test.ts b/packages/cache/__tests__/cacheTwirpClient.test.ts new file mode 100644 index 00000000..d74bfd3a --- /dev/null +++ b/packages/cache/__tests__/cacheTwirpClient.test.ts @@ -0,0 +1,174 @@ +import * as http from 'http' +import * as net from 'net' +import {HttpClient} from '@actions/http-client' +import * as core from '@actions/core' +import * as config from '../src/internal/config' +import * as cacheUtils from '../src/internal/cacheUtils' +import {internalCacheTwirpClient} from '../src/internal/shared/cacheTwirpClient' + +jest.mock('@actions/http-client') + +const clientOptions = { + maxAttempts: 5, + retryIntervalMs: 1, + retryMultiplier: 1.5 +} + +// noopLogs mocks the console.log and core.* functions to prevent output in the console while testing +const noopLogs = (): void => { + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(core, 'debug').mockImplementation(() => {}) + jest.spyOn(core, 'info').mockImplementation(() => {}) + jest.spyOn(core, 'warning').mockImplementation(() => {}) +} + +describe('cacheTwirpClient', () => { + beforeAll(() => { + noopLogs() + jest + .spyOn(config, 'getCacheServiceURL') + .mockReturnValue('http://localhost:8080') + jest.spyOn(cacheUtils, 'getRuntimeToken').mockReturnValue('token') + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should fail immediately on 429 rate limit without retrying', async () => { + const mockPost = jest.fn(() => { + const msg = new http.IncomingMessage(new net.Socket()) + msg.statusCode = 429 + msg.statusMessage = 'Too Many Requests' + return { + message: msg, + readBody: async () => { + return Promise.resolve(`{"ok": false}`) + } + } + }) + + ;(HttpClient as unknown as jest.Mock).mockImplementation(() => { + return { + post: mockPost + } + }) + + const client = internalCacheTwirpClient(clientOptions) + await expect( + client.CreateCacheEntry({ + key: 'test-key', + version: 'test-version' + }) + ).rejects.toThrow( + 'Failed to CreateCacheEntry: Rate limited: Failed request: (429) Too Many Requests' + ) + + // Should only be called once - no retries for 429 + expect(mockPost).toHaveBeenCalledTimes(1) + }) + + it('should log warning with retry-after header on 429', async () => { + const warningSpy = jest.spyOn(core, 'warning') + + const mockPost = jest.fn(() => { + const msg = new http.IncomingMessage(new net.Socket()) + msg.statusCode = 429 + msg.statusMessage = 'Too Many Requests' + msg.headers = {'retry-after': '60'} + return { + message: msg, + readBody: async () => { + return Promise.resolve(`{"ok": false}`) + } + } + }) + + ;(HttpClient as unknown as jest.Mock).mockImplementation(() => { + return { + post: mockPost + } + }) + + const client = internalCacheTwirpClient(clientOptions) + await expect( + client.CreateCacheEntry({ + key: 'test-key', + version: 'test-version' + }) + ).rejects.toThrow('Rate limited') + + expect(mockPost).toHaveBeenCalledTimes(1) + expect(warningSpy).toHaveBeenCalledWith( + "You've hit a rate limit, your rate limit will reset in 60 seconds" + ) + }) + + it('should not log warning if retry-after header is missing on 429', async () => { + const warningSpy = jest.spyOn(core, 'warning') + + const mockPost = jest.fn(() => { + const msg = new http.IncomingMessage(new net.Socket()) + msg.statusCode = 429 + msg.statusMessage = 'Too Many Requests' + // No retry-after header + return { + message: msg, + readBody: async () => { + return Promise.resolve(`{"ok": false}`) + } + } + }) + + ;(HttpClient as unknown as jest.Mock).mockImplementation(() => { + return { + post: mockPost + } + }) + + const client = internalCacheTwirpClient(clientOptions) + await expect( + client.CreateCacheEntry({ + key: 'test-key', + version: 'test-version' + }) + ).rejects.toThrow('Rate limited') + + expect(mockPost).toHaveBeenCalledTimes(1) + expect(warningSpy).not.toHaveBeenCalled() + }) + + it('should not log warning if retry-after header is invalid on 429', async () => { + const warningSpy = jest.spyOn(core, 'warning') + + const mockPost = jest.fn(() => { + const msg = new http.IncomingMessage(new net.Socket()) + msg.statusCode = 429 + msg.statusMessage = 'Too Many Requests' + msg.headers = {'retry-after': 'invalid'} + return { + message: msg, + readBody: async () => { + return Promise.resolve(`{"ok": false}`) + } + } + }) + + ;(HttpClient as unknown as jest.Mock).mockImplementation(() => { + return { + post: mockPost + } + }) + + const client = internalCacheTwirpClient(clientOptions) + await expect( + client.CreateCacheEntry({ + key: 'test-key', + version: 'test-version' + }) + ).rejects.toThrow('Rate limited') + + expect(mockPost).toHaveBeenCalledTimes(1) + expect(warningSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cache/src/internal/shared/cacheTwirpClient.ts b/packages/cache/src/internal/shared/cacheTwirpClient.ts index ade1b8c9..72bd6c0a 100644 --- a/packages/cache/src/internal/shared/cacheTwirpClient.ts +++ b/packages/cache/src/internal/shared/cacheTwirpClient.ts @@ -136,6 +136,11 @@ class CacheServiceClient implements Rpc { throw new NetworkError(error?.code) } + // Re-throw rate limit errors without retry + if (error.message?.startsWith('Rate limited:')) { + throw error + } + isRetryable = true errorMessage = error.message } From 4a47af6481563165aa9def9a2458273c8d66fa17 Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi Date: Fri, 16 Jan 2026 09:46:28 +0000 Subject: [PATCH 3/7] bump pkg --- packages/cache/RELEASES.md | 2 +- packages/cache/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md index 140f17db..a371600e 100644 --- a/packages/cache/RELEASES.md +++ b/packages/cache/RELEASES.md @@ -1,6 +1,6 @@ # @actions/cache Releases -### 5.0.2 +### 5.0.3 Fail cache saves on rate limit errors from the cache service to prevent slowing down impacted runs diff --git a/packages/cache/package.json b/packages/cache/package.json index ca2e145c..eb7531ed 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@actions/cache", - "version": "5.0.2", + "version": "5.0.3", "preview": true, "description": "Actions cache lib", "keywords": [ From 7292b3508fd77363fecc3fd1cab201155368dd7a Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi Date: Fri, 16 Jan 2026 09:51:21 +0000 Subject: [PATCH 4/7] update release doc --- packages/cache/RELEASES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md index a371600e..8ae33f8a 100644 --- a/packages/cache/RELEASES.md +++ b/packages/cache/RELEASES.md @@ -2,7 +2,7 @@ ### 5.0.3 -Fail cache saves on rate limit errors from the cache service to prevent slowing down impacted runs +Fail cache saves on rate limit errors from the cache service to prevent slowing down impacted runs [2243](https://github.com/actions/toolkit/pull/2243). ### 5.0.1 From dd1bb93c725dd3d0d61e236f709e0cadd3fe3bfa Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi Date: Fri, 16 Jan 2026 10:00:15 +0000 Subject: [PATCH 5/7] New error type for cache RL --- packages/cache/RELEASES.md | 2 +- .../cache/src/internal/shared/cacheTwirpClient.ts | 13 ++++++------- packages/cache/src/internal/shared/errors.ts | 7 +++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md index 8ae33f8a..821c80aa 100644 --- a/packages/cache/RELEASES.md +++ b/packages/cache/RELEASES.md @@ -2,7 +2,7 @@ ### 5.0.3 -Fail cache saves on rate limit errors from the cache service to prevent slowing down impacted runs [2243](https://github.com/actions/toolkit/pull/2243). +Fail cache operations on rate limit errors from the cache service to prevent slowing down impacted runs [2243](https://github.com/actions/toolkit/pull/2243). ### 5.0.1 diff --git a/packages/cache/src/internal/shared/cacheTwirpClient.ts b/packages/cache/src/internal/shared/cacheTwirpClient.ts index 72bd6c0a..c1e14240 100644 --- a/packages/cache/src/internal/shared/cacheTwirpClient.ts +++ b/packages/cache/src/internal/shared/cacheTwirpClient.ts @@ -1,6 +1,6 @@ import {info, debug, warning} from '@actions/core' import {getUserAgentString} from './user-agent' -import {NetworkError, UsageError} from './errors' +import {NetworkError, RateLimitError, UsageError} from './errors' import {getCacheServiceURL} from '../config' import {getRuntimeToken} from '../cacheUtils' import {BearerCredentialHandler} from '@actions/http-client/lib/auth' @@ -121,7 +121,7 @@ class CacheServiceClient implements Rpc { ) } } - throw new Error(`Rate limited: ${errorMessage}`) + throw new RateLimitError(`Rate limited: ${errorMessage}`) } } catch (error) { if (error instanceof SyntaxError) { @@ -132,13 +132,12 @@ class CacheServiceClient implements Rpc { throw error } - if (NetworkError.isNetworkErrorCode(error?.code)) { - throw new NetworkError(error?.code) + if (error instanceof RateLimitError) { + throw error } - // Re-throw rate limit errors without retry - if (error.message?.startsWith('Rate limited:')) { - throw error + if (NetworkError.isNetworkErrorCode(error?.code)) { + throw new NetworkError(error?.code) } isRetryable = true diff --git a/packages/cache/src/internal/shared/errors.ts b/packages/cache/src/internal/shared/errors.ts index 9ec29f6b..5e90a299 100644 --- a/packages/cache/src/internal/shared/errors.ts +++ b/packages/cache/src/internal/shared/errors.ts @@ -70,3 +70,10 @@ export class UsageError extends Error { return msg.includes('insufficient usage') } } + +export class RateLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'RateLimitError' + } +} From 9dd77993e7dee404bba7488380ab2a9768d482da Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi <114221941+GhadimiR@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:25:44 +0000 Subject: [PATCH 6/7] Update packages/cache/RELEASES.md Co-authored-by: Bassem Dghaidi <568794+Link-@users.noreply.github.com> --- packages/cache/RELEASES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md index 821c80aa..3acb9fd6 100644 --- a/packages/cache/RELEASES.md +++ b/packages/cache/RELEASES.md @@ -2,7 +2,7 @@ ### 5.0.3 -Fail cache operations on rate limit errors from the cache service to prevent slowing down impacted runs [2243](https://github.com/actions/toolkit/pull/2243). +Prevent retries for rate limited cache operations [2243](https://github.com/actions/toolkit/pull/2243). ### 5.0.1 From a039cff4a10e8baa46b66b270f19706969948d81 Mon Sep 17 00:00:00 2001 From: Ryan Ghadimi <114221941+GhadimiR@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:26:45 +0000 Subject: [PATCH 7/7] Add comment for rate limiting handling Added a comment regarding rate limiting and retry behavior. --- packages/cache/src/internal/shared/cacheTwirpClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cache/src/internal/shared/cacheTwirpClient.ts b/packages/cache/src/internal/shared/cacheTwirpClient.ts index c1e14240..a6e2d0ad 100644 --- a/packages/cache/src/internal/shared/cacheTwirpClient.ts +++ b/packages/cache/src/internal/shared/cacheTwirpClient.ts @@ -111,6 +111,7 @@ class CacheServiceClient implements Rpc { } // Handle rate limiting - don't retry, just warn and exit + // For more info, see https://docs.github.com/en/actions/reference/limits if (statusCode === HttpCodes.TooManyRequests) { const retryAfterHeader = response.message.headers['retry-after'] if (retryAfterHeader) {