diff --git a/package.json b/package.json index ab5aa81..8bc2e7b 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,6 @@ "@fastify/swagger": "^9.6.1", "@fastify/swagger-ui": "^5.2.5", "@sentry/node": "^10.48.0", - "axios": "^1.4.0", - "dotenv": "^16.0.0", "fastify": "^5.7.4", "ioredis": "^5.3.1", "pino": "^10.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55dc2a0..2210a95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,6 @@ importers: '@sentry/node': specifier: ^10.48.0 version: 10.48.0 - axios: - specifier: ^1.4.0 - version: 1.13.5 - dotenv: - specifier: ^16.0.0 - version: 16.6.1 fastify: specifier: ^5.7.4 version: 5.8.1 @@ -916,9 +910,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -926,9 +917,6 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -951,10 +939,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -970,10 +954,6 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1005,10 +985,6 @@ packages: supports-color: optional: true - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1025,33 +1001,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1111,19 +1063,6 @@ packages: resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -1132,17 +1071,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1151,26 +1079,10 @@ packages: resolution: {integrity: sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==} engines: {node: 20 || >=22} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - helmet@8.1.0: resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} engines: {node: '>=18.0.0'} @@ -1245,18 +1157,6 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -1387,9 +1287,6 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -2416,8 +2313,6 @@ snapshots: assertion-error@2.0.1: {} - asynckit@0.4.0: {} - atomic-sleep@1.0.0: {} avvio@9.2.0: @@ -2425,14 +2320,6 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axios@1.13.5: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2452,11 +2339,6 @@ snapshots: dependencies: fill-range: 7.1.1 - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - chai@6.2.2: {} chokidar@3.6.0: @@ -2475,10 +2357,6 @@ snapshots: cluster-key-slot@1.1.2: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - concat-map@0.0.1: {} content-disposition@1.0.1: {} @@ -2497,8 +2375,6 @@ snapshots: dependencies: ms: 2.1.3 - delayed-stream@1.0.0: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -2507,31 +2383,8 @@ snapshots: diff@4.0.2: {} - dotenv@16.6.1: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -2630,41 +2483,11 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.0.0 - follow-redirects@1.15.11: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - forwarded-parse@2.1.2: {} fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2675,20 +2498,8 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 - gopd@1.2.0: {} - has-flag@3.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - helmet@8.1.0: {} http-errors@2.0.1: @@ -2777,14 +2588,6 @@ snapshots: make-error@1.3.6: {} - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime@3.0.0: {} minimatch@10.1.2: @@ -2917,8 +2720,6 @@ snapshots: process-warning@5.0.0: {} - proxy-from-env@1.1.0: {} - pstree.remy@1.1.8: {} quick-format-unescaped@4.0.4: {} diff --git a/src/config/env.ts b/src/config/env.ts index 5611caa..8721d57 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,7 +1,10 @@ import path from 'node:path'; -import dotenv from 'dotenv'; -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +try { + process.loadEnvFile(path.resolve(process.cwd(), '.env')); +} catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; +} export const PORT = process.env.PORT ? Number(process.env.PORT) : 3001; export const NODE_ENV = process.env.NODE_ENV || 'development'; diff --git a/src/errors/groqApiError.ts b/src/errors/groqApiError.ts index 5e079e4..e3fd3ee 100644 --- a/src/errors/groqApiError.ts +++ b/src/errors/groqApiError.ts @@ -1,9 +1,9 @@ -import { AxiosError } from 'axios'; import type { ApiError } from '../types/api'; -// Sanitized snapshot of an upstream error. Excludes axios internals -// (config, request, response) which carry the bearer token in headers -// and would leak through pino's default err serializer. +// Sanitized snapshot of an upstream error. Kept as a normalizer for pino's +// err serializer so arbitrary non-Error values become loggable Error objects. +// With axios removed, no built-in client surfaces request headers on errors, +// so this no longer needs to strip provider-specific internals. interface SafeErrorSnapshot { code?: string; status?: number; @@ -11,22 +11,11 @@ interface SafeErrorSnapshot { } /** - * Strip axios internals from an error so it is safe to log or attach - * to a thrown {@link GroqApiError}. AxiosError exposes `config.headers` - * (and `response.config.headers`) which contain the `Authorization` - * bearer; pino-std-serializers spreads own-enumerable properties, so - * any `{ err }` log call leaks the token. Always pass arbitrary errors - * through this helper before storing or logging. + * Normalize an unknown value into an Error. Errors pass through unchanged + * (HttpError, GroqApiError, etc. retain their type for instanceof checks); + * non-Error values are wrapped so pino's `{ err }` serializer can handle them. */ export function toSafeError(err: unknown): Error & SafeErrorSnapshot { - if (err instanceof AxiosError) { - const safe = new Error(err.message) as Error & SafeErrorSnapshot; - safe.name = 'AxiosError'; - safe.code = err.code; - safe.status = err.response?.status; - if (err.stack) safe.stack = err.stack; - return safe; - } if (err instanceof Error) { return err as Error & SafeErrorSnapshot; } diff --git a/src/lib/__tests__/groqClient.test.ts b/src/lib/__tests__/groqClient.test.ts index 76fbba4..e1854bd 100644 --- a/src/lib/__tests__/groqClient.test.ts +++ b/src/lib/__tests__/groqClient.test.ts @@ -1,49 +1,38 @@ -import { AxiosError } from 'axios'; -import { describe, expect, it } from 'vitest'; -import { GroqApiError, toSafeError } from '../../errors/groqApiError'; - -const CANARY = 'TEST_BEARER_CANARY_42'; - -function makeAxiosError() { - const config = { - url: 'https://api.groq.com/openai/v1/chat/completions', - method: 'post', - headers: { - Authorization: `Bearer ${CANARY}`, - 'Content-Type': 'application/json', - }, - }; - return new AxiosError('Request failed with status code 401', 'ERR_BAD_REQUEST', config, null, { - status: 401, - statusText: 'Unauthorized', - headers: {}, - config, - data: {}, - }); -} +import { describe, expect, it, vi } from 'vitest'; -describe('toSafeError (bearer-leak regression)', () => { - it('strips axios config/request/response so JSON serialization carries no bearer', () => { - const safe = toSafeError(makeAxiosError()); - const json = JSON.stringify(safe); - expect(json).not.toContain(CANARY); - expect(json).not.toContain('Bearer'); - expect(json).not.toContain('Authorization'); - }); +vi.mock('../../config/env', () => ({ + GROQ_API_KEY: 'test-key', + GROQ_BACKOFF_BASE_MS: () => 100, + GROQ_EMPTY_RESPONSE_RETRIES: () => 1, + GROQ_MAX_RETRIES: () => 2, + GROQ_MAX_TOKENS: () => 1024, + GROQ_MAX_TOKENS_RETRY: () => 2048, + GROQ_MODEL: 'test-model', + GROQ_TIMEOUT_MS: () => 5000, + MODEL_CB_COOLDOWN_MS: 30000, + MODEL_CB_FAILURES: 3, + BUILD_VERSION: 'test', + LOG_LEVEL: 'silent', + NODE_ENV: 'test', +})); - it('preserves status + code metadata on the sanitized error', () => { - const safe = toSafeError(makeAxiosError()); - expect(safe.message).toBe('Request failed with status code 401'); - expect(safe.name).toBe('AxiosError'); - expect(safe.code).toBe('ERR_BAD_REQUEST'); - expect(safe.status).toBe(401); - }); +import { GroqApiError, toSafeError } from '../../errors/groqApiError'; +import { HttpError } from '../groqClient'; - it('passes non-axios errors through unchanged', () => { +describe('toSafeError', () => { + it('passes Error instances through unchanged', () => { const e = new Error('boom'); expect(toSafeError(e)).toBe(e); }); + it('preserves HttpError identity (instanceof + status) so retryability checks still work', () => { + const http = new HttpError(429, 'rate limited', { error: 'too many' }); + const safe = toSafeError(http); + expect(safe).toBe(http); + expect(safe).toBeInstanceOf(HttpError); + expect((safe as HttpError).status).toBe(429); + }); + it('wraps non-Error values', () => { const safe = toSafeError('plain string'); expect(safe).toBeInstanceOf(Error); @@ -51,10 +40,24 @@ describe('toSafeError (bearer-leak regression)', () => { }); }); +describe('HttpError (bearer-leak surface)', () => { + it('carries only status + message + parsed body — no request headers', () => { + // Regression guard: with axios gone, the Authorization header lives only + // on the fetch options object in postChatCompletion and never escapes onto + // the thrown error. JSON-serializing the HttpError must not surface it. + const http = new HttpError(401, 'HTTP 401 Unauthorized', { error: 'bad key' }); + const json = JSON.stringify(http); + expect(json).not.toContain('Authorization'); + expect(json).not.toContain('Bearer'); + }); +}); + describe('GroqApiError', () => { - it('does not leak the bearer when constructed from a sanitized snapshot', () => { - const safe = toSafeError(makeAxiosError()); - const wrapped = new GroqApiError('Groq API error (401): unauthorized', 401, false, safe); - expect(JSON.stringify({ err: wrapped })).not.toContain(CANARY); + it('wraps a sanitized originalError without leaking arbitrary fields', () => { + const safe = toSafeError(new Error('upstream blew up')); + const wrapped = new GroqApiError('Groq API error (500): upstream blew up', 500, true, safe); + expect(wrapped.status).toBe(500); + expect(wrapped.isRetryable).toBe(true); + expect(wrapped.originalError).toBe(safe); }); }); diff --git a/src/lib/groqClient.ts b/src/lib/groqClient.ts index 1ec8c94..7ff32fc 100644 --- a/src/lib/groqClient.ts +++ b/src/lib/groqClient.ts @@ -1,4 +1,3 @@ -import axios, { AxiosError } from 'axios'; import { GROQ_API_KEY, GROQ_BACKOFF_BASE_MS, @@ -30,6 +29,22 @@ export interface GroqResponse { const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; +/** + * Carries upstream HTTP status from non-2xx Groq responses. Body is the parsed + * JSON payload (or raw text fallback) — never the request, so the bearer token + * stays on the local stack frame and cannot leak through pino's err serializer. + */ +export class HttpError extends Error { + readonly status: number; + readonly body: unknown; + constructor(status: number, message: string, body: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.body = body; + } +} + function selectModel(challengeType?: ChallengeType): string { if (!challengeType) { return GROQ_MODEL; @@ -61,6 +76,47 @@ interface GroqApiResult { completionTokens?: number; } +interface GroqUsage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; +} + +interface GroqApiResponse { + choices?: Array<{ message?: { content?: string } }>; + model?: string; + usage?: GroqUsage; +} + +async function postChatCompletion( + body: Record, + timeoutMs: number, +): Promise { + const res = await fetch(GROQ_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${GROQ_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!res.ok) { + let parsed: unknown; + const text = await res.text().catch(() => ''); + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = text; + } + throw new HttpError(res.status, `HTTP ${res.status} ${res.statusText}`, parsed); + } + + return (await res.json()) as GroqApiResponse; +} + /** * Makes a single API call to Groq with retry logic for transient errors. * Returns the result or throws an error. @@ -76,8 +132,7 @@ async function makeGroqApiCall( const maxRetries = GROQ_MAX_RETRIES(); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const res = await axios.post( - GROQ_API_URL, + const data = await postChatCompletion( { model: selectModel(challengeType), messages: [ @@ -87,16 +142,9 @@ async function makeGroqApiCall( max_tokens: maxTokens, temperature: 0.5, }, - { - headers: { - Authorization: `Bearer ${GROQ_API_KEY}`, - 'Content-Type': 'application/json', - }, - timeout: GROQ_TIMEOUT_MS(), - }, + GROQ_TIMEOUT_MS(), ); - const data = res.data; const hint = data.choices?.[0]?.message?.content || ''; const model_used = data.model || GROQ_MODEL; const completionTokens = data.usage?.completion_tokens; @@ -105,7 +153,7 @@ async function makeGroqApiCall( if (data.usage) { const cachedTokens = data.usage.prompt_tokens_details?.cached_tokens || 0; const cacheHitRate = - data.usage.prompt_tokens > 0 + data.usage.prompt_tokens && data.usage.prompt_tokens > 0 ? ((cachedTokens / data.usage.prompt_tokens) * 100).toFixed(1) : '0.0'; @@ -129,18 +177,13 @@ async function makeGroqApiCall( return { hint: hint.trim(), model_used, completionTokens }; } catch (err) { - // Compute retryability while the raw error (incl. AxiosError shape) is - // available, then immediately strip axios internals. From this point - // forward `lastError` carries no `config`/`request`/`response`, so it is - // safe to feed to pino's err serializer without leaking the bearer. const raw = err instanceof Error ? err : new Error(String(err)); const retryable = isRetryableError(raw); - const status = raw instanceof AxiosError ? raw.response?.status : undefined; lastError = toSafeError(raw); if (!retryable) { handleNonRetryableError(lastError, logger); - throw createGroqError(lastError, status); + throw createGroqError(lastError); } logger.warn({ attempt, maxRetries, err: lastError }, 'groq request failed'); @@ -243,19 +286,30 @@ export async function generateFromGroq(options: GroqRequestOptions): Promise= 500 || status === 429) return true; +function isTimeoutOrNetworkError(err: Error): boolean { + // AbortSignal.timeout() produces DOMException name='TimeoutError'; manual aborts -> 'AbortError'. + if (err.name === 'TimeoutError' || err.name === 'AbortError') return true; + // Undici / Node fetch network failures surface as TypeError with cause carrying ECONN*/ENOTFOUND. + const cause = (err as { cause?: { code?: string } }).cause; + if ( + cause?.code && + /^E(CONN|TIMEDOUT|NETUNREACH|HOSTUNREACH|NOTFOUND|PIPE|RESET)/.test(cause.code) + ) + return true; + if (err.message.includes('timeout') || err.message.includes('ECONN')) return true; + return false; +} - // Don't retry on client errors (4xx) except rate limit (429) - if (status >= 400 && status < 500 && status !== 429) return false; +function isRetryableError(err: Error): boolean { + if (err instanceof HttpError) { + const status = err.status; + // Retry on 5xx and 429; don't retry on other 4xx + if (status >= 500 || status === 429) return true; + if (status >= 400 && status < 500) return false; + return true; } - // Retry on timeout and network errors - if (err.message.includes('timeout') || err.message.includes('ECONN')) return true; + if (isTimeoutOrNetworkError(err)) return true; // Default to retry for unknown errors return true; @@ -287,17 +341,15 @@ function handleAllRetriesFailed( } } -function createGroqError(err: Error, axiosStatus?: number): Error { - // `err` is already a sanitized snapshot (see catch block above), so we - // can't `instanceof AxiosError`-check it. The caller passes the original - // HTTP status via `axiosStatus` when the source was an AxiosError. - if (axiosStatus !== undefined) { +function createGroqError(err: Error): Error { + if (err instanceof HttpError) { + const status = err.status; const isRetryable = isRetryableError(err); - const message = `Groq API error (${axiosStatus}): ${err.message}`; - return new GroqApiError(message, axiosStatus, isRetryable, err); + const message = `Groq API error (${status}): ${err.message}`; + return new GroqApiError(message, status, isRetryable, err); } - // For non-axios errors, check if they're retryable + // For non-HTTP errors, check if they're retryable const isRetryable = isRetryableError(err); if (isRetryable) { return new GroqApiError(`Groq request error: ${err.message}`, 503, true, err); diff --git a/src/routes/health.ts b/src/routes/health.ts index 5a98617..cadc65e 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,8 +1,11 @@ -import axios from 'axios'; import type { FastifyInstance } from 'fastify'; import { BUILD_VERSION, ENABLE_EXTENDED_HEALTH, GROQ_API_KEY } from '../config/env'; -async function healthRoutes(fastify: FastifyInstance) { +interface GroqModelsResponse { + data?: unknown[]; +} + +async function healthRoutes(fastify: FastifyInstance): Promise { fastify.get( '/health', { @@ -34,13 +37,15 @@ async function healthRoutes(fastify: FastifyInstance) { })(), (async () => { try { - const r = await axios.get('https://api.groq.com/openai/v1/models', { - headers: { - Authorization: `Bearer ${GROQ_API_KEY}`, - }, - timeout: 5000, + const r = await fetch('https://api.groq.com/openai/v1/models', { + headers: { Authorization: `Bearer ${GROQ_API_KEY}` }, + signal: AbortSignal.timeout(5000), }); - return { groq: 'ok', models_count: r.data?.data?.length || 0 }; + if (!r.ok) { + return { groq: 'error', error: `HTTP ${r.status} ${r.statusText}` }; + } + const body = (await r.json()) as GroqModelsResponse; + return { groq: 'ok', models_count: body.data?.length || 0 }; } catch (e: unknown) { return { groq: 'error', error: e instanceof Error ? e.message : String(e) }; }