diff --git a/CLAUDE.md b/CLAUDE.md index 526db5a..24d72d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,11 +19,10 @@ Non-obvious: `apiKeyAuthHook` is an `onRequest` hook registered inside `hintRout ## Observability -- `src/instrument.ts` initializes Sentry and MUST stay the literal first import of `src/index.ts`. Sentry patches `http`/axios at require time; any earlier import escapes instrumentation. Do NOT replace with `node --import ./instrument.mjs` unless you also delete the import. (Invariant V1.) +- `src/instrument.ts` initializes Sentry and MUST stay the literal first import of `src/index.ts`. Sentry patches `http` at require time; any earlier import escapes instrumentation. Do NOT replace with `node --import ./instrument.mjs` unless you also delete the import. (Invariant V1.) - Handlers/hooks: `request.log` (child logger, `reqId` auto-bound). Module scope: `rootLogger`. Library code takes the structural `Logger` type from `src/config/logger.ts` instead of importing pino. - **Pino argument order is object-first** (opposite of winston): `logger.info({ foo: 1 }, 'msg')`. - **Use `{ err }` as the key.** Pino's err serializer only fires on the literal key `err`; `{ error: ... }` silently dumps unserialized. -- **Never log raw `AxiosError` objects.** `err.config.headers` carries `Authorization: Bearer ...`. Pass through `toSafeError()` from `src/errors/groqApiError.ts` first. - Sentry Logs ships `error` + `fatal` only; warn-level ops signals stay in pino stdout. Sentry traces skip `/health` + `/health/version` (probe quota). - **Graceful shutdown flushes Sentry.** Every exit path must `await Sentry.close(2000)` before `process.exit`. Safe no-op when no `SENTRY_DSN`. (Invariant V4.) - **`/hint` body NOT in Sentry** — `sendDefaultPii: false` gates request-body capture (verified 2026-06-30, SOCRATES-API-3: method+URL only, no `request.data`). `beforeSend` body-scrub is dead. Only learner-content path = `includeLocalVariables: true` (stack locals). Capturing the body needs `sendDefaultPii: true` + `maxRequestBodySize`. diff --git a/package.json b/package.json index 32dbb6c..139362d 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,6 @@ "@fastify/swagger": "^9.8.1", "@fastify/swagger-ui": "^6.1.1", "@sentry/node": "^10.73.0", - "axios": "^1.20.0", - "dotenv": "^17.4.2", "fastify": "^5.12.3", "ioredis": "^6.0.0", "pino": "^10.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1f370d..e7cc850 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,12 +181,6 @@ importers: '@sentry/node': specifier: ^10.73.0 version: 10.76.2(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(supports-color@5.5.0) - axios: - specifier: ^1.20.0 - version: 1.20.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0) - dotenv: - specifier: ^17.4.2 - version: 17.4.2 fastify: specifier: ^5.12.3 version: 5.12.5 @@ -896,10 +890,6 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -919,9 +909,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'} @@ -929,9 +916,6 @@ packages: avvio@9.3.0: resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} - axios@1.20.0: - resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -948,10 +932,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'} @@ -967,10 +947,6 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - content-disposition@2.0.1: resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} @@ -988,10 +964,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'} @@ -1008,36 +980,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - 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@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-module-lexer@3.0.2: resolution: {integrity: sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==} - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -1097,35 +1045,11 @@ packages: resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} engines: {node: '>=20'} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 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'} @@ -1134,26 +1058,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 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.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - helmet@8.3.0: resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} engines: {node: '>=18.0.0'} @@ -1162,10 +1070,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} @@ -1294,18 +1198,6 @@ packages: magic-string@1.3.1: resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} - 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'} @@ -1403,10 +1295,6 @@ packages: process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -2091,12 +1979,6 @@ snapshots: abstract-logging@2.0.1: {} - agent-base@6.0.2(supports-color@5.5.0): - dependencies: - debug: 4.4.3(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -2115,8 +1997,6 @@ snapshots: assertion-error@2.0.1: {} - asynckit@0.4.0: {} - atomic-sleep@1.0.0: {} avvio@9.3.0: @@ -2124,16 +2004,6 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.3 - axios@1.20.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0): - dependencies: - follow-redirects: 1.16.0(debug@4.4.3(supports-color@5.5.0)) - form-data: 4.0.6 - https-proxy-agent: 5.0.1(supports-color@5.5.0) - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - balanced-match@4.0.4: {} binary-extensions@2.3.0: {} @@ -2146,11 +2016,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: @@ -2169,10 +2034,6 @@ snapshots: cluster-key-slot@1.1.1: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - content-disposition@2.0.1: {} cookie@1.1.1: {} @@ -2183,8 +2044,6 @@ snapshots: optionalDependencies: supports-color: 5.5.0 - delayed-stream@1.0.0: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -2193,33 +2052,10 @@ snapshots: detect-libc@2.1.2: {} - dotenv@17.4.2: {} - - 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@2.3.2: {} es-module-lexer@3.0.2: {} - es-object-atoms@1.1.2: - 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.4 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -2316,41 +2152,9 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 - follow-redirects@1.16.0(debug@4.4.3(supports-color@5.5.0)): - optionalDependencies: - debug: 4.4.3(supports-color@5.5.0) - - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - 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.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2361,20 +2165,8 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - 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.4: - dependencies: - function-bind: 1.1.2 - helmet@8.3.0: {} http-errors@2.0.1: @@ -2385,13 +2177,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1(supports-color@5.5.0): - dependencies: - agent-base: 6.0.2(supports-color@5.5.0) - debug: 4.4.3(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - ignore-by-default@1.0.1: {} import-in-the-middle@3.5.0: @@ -2502,14 +2287,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - 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.2.6: @@ -2610,8 +2387,6 @@ snapshots: process-warning@5.1.0: {} - proxy-from-env@2.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 584dcd5..579a3ee 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,7 +1,9 @@ import path from 'node:path'; -import dotenv from 'dotenv'; - -dotenv.config({ path: path.resolve(process.cwd(), '.env'), quiet: true }); +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 b17c0cb..0b3ec4d 100644 --- a/src/errors/groqApiError.ts +++ b/src/errors/groqApiError.ts @@ -1,49 +1,26 @@ -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. -interface SafeErrorSnapshot { - code?: string; - status?: number; - stack?: string; -} +export class HttpError extends Error { + status: number; -/** - * 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. - */ -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; + constructor(status: number, code?: string) { + super(code ? `HTTP ${status} (${code})` : `HTTP ${status}`); + this.name = 'HttpError'; + this.status = status; } - return new Error(String(err)) as Error & SafeErrorSnapshot; } export class GroqApiError extends Error implements ApiError { status: number; isRetryable: boolean; upstreamStatus?: number; - originalError?: SafeErrorSnapshot; + originalError?: Error; constructor( message: string, status: number, isRetryable: boolean, - originalError?: SafeErrorSnapshot, + originalError?: Error, upstreamStatus?: number, ) { super(message); diff --git a/src/instrument.ts b/src/instrument.ts index 2ae99c9..a341764 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -1,5 +1,5 @@ // This file MUST be imported as the literal first line of src/index.ts. -// Sentry's Node SDK patches http/https (and therefore axios) at require time, +// Sentry's Node SDK patches http/https at require time, // so any module imported before this file will not be instrumented. Do not // move this import. Do not import this file from tests. import * as Sentry from '@sentry/node'; diff --git a/src/lib/__tests__/groqClient.emptyCircuit.test.ts b/src/lib/__tests__/groqClient.emptyCircuit.test.ts index 2b79502..b4a3fdf 100644 --- a/src/lib/__tests__/groqClient.emptyCircuit.test.ts +++ b/src/lib/__tests__/groqClient.emptyCircuit.test.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelUnavailableError } from '../../errors/modelUnavailableError'; @@ -18,13 +17,8 @@ vi.mock('../../config/env', () => ({ MODEL_CB_COOLDOWN_MS: 30000, })); -vi.mock('axios', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - default: { ...actual.default, post: vi.fn() }, - }; -}); +const fetchMock = vi.fn(); +vi.stubGlobal('fetch', fetchMock); vi.mock('@sentry/node', () => ({ startSpan: (_opts: unknown, cb: (span: unknown) => unknown) => @@ -47,17 +41,21 @@ async function call() { describe('generateFromGroq empty-response circuit breaker', () => { beforeEach(() => { - vi.mocked(axios.post).mockResolvedValue({ - data: { model: 'test-model', choices: [{ message: { content: ' \n\t' } }] }, - }); + fetchMock.mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ model: 'test-model', choices: [{ message: { content: ' \n\t' } }] }), + ), + ), + ); }); it('opens the circuit after consecutive exhausted whitespace-only responses', async () => { await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); - expect(axios.post).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); await expect(call()).rejects.toThrow('Model circuit breaker is open'); - expect(axios.post).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); diff --git a/src/lib/__tests__/groqClient.fallback.test.ts b/src/lib/__tests__/groqClient.fallback.test.ts index e246313..51382ce 100644 --- a/src/lib/__tests__/groqClient.fallback.test.ts +++ b/src/lib/__tests__/groqClient.fallback.test.ts @@ -1,4 +1,3 @@ -import axios, { AxiosError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GroqApiError } from '../../errors/groqApiError'; import { ModelUnavailableError } from '../../errors/modelUnavailableError'; @@ -19,14 +18,6 @@ vi.mock('../../config/env', () => ({ MODEL_CB_COOLDOWN_MS: 30000, })); -vi.mock('axios', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - default: { ...actual.default, post: vi.fn() }, - }; -}); - vi.mock('@sentry/node', () => ({ startSpan: (_opts: unknown, cb: (span: unknown) => unknown) => cb({ setAttribute: () => {}, setAttributes: () => {} }), @@ -41,19 +32,25 @@ const silentLogger = { trace: vi.fn(), }; +const fetchMock = vi.fn(); +vi.stubGlobal('fetch', fetchMock); + function timeoutError() { - return new AxiosError('timeout of 30000ms exceeded', 'ECONNABORTED'); + return new DOMException('The operation was aborted due to timeout', 'TimeoutError'); } function httpError(status: number) { - const config = { url: 'https://groq', method: 'post', headers: {} }; - return new AxiosError( - `Request failed with status code ${status}`, - 'ERR_BAD_RESPONSE', - config, - null, - { status, statusText: '', headers: {}, config, data: {} }, - ); + return () => + Promise.resolve( + new Response( + JSON.stringify({ error: { message: 'Invalid API Key', code: 'invalid_api_key' } }), + { status }, + ), + ); +} + +function jsonResponse(data: unknown) { + return () => Promise.resolve(new Response(JSON.stringify(data))); } async function call() { @@ -68,28 +65,28 @@ async function call() { describe('generateFromGroq transient-failure handling', () => { beforeEach(() => { - vi.mocked(axios.post).mockReset(); + fetchMock.mockReset(); for (const fn of Object.values(silentLogger)) fn.mockReset(); }); it('throws ModelUnavailableError when all retries fail on a timeout', async () => { - vi.mocked(axios.post).mockRejectedValue(timeoutError()); + fetchMock.mockRejectedValue(timeoutError()); await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); }); it('throws ModelUnavailableError when all retries fail on a 503', async () => { - vi.mocked(axios.post).mockRejectedValue(httpError(503)); + fetchMock.mockImplementation(httpError(503)); await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); }); it('does not log at error level for a transient exhausted-retry failure', async () => { - vi.mocked(axios.post).mockRejectedValue(timeoutError()); + fetchMock.mockRejectedValue(timeoutError()); await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); expect(silentLogger.error).not.toHaveBeenCalled(); }); it('still surfaces a non-retryable 401 as GroqApiError (genuine bug, Sentry-visible)', async () => { - vi.mocked(axios.post).mockRejectedValue(httpError(401)); + fetchMock.mockImplementation(httpError(401)); const error = await call().catch((err: unknown) => err); expect(error).toBeInstanceOf(GroqApiError); expect(error).toMatchObject({ @@ -100,14 +97,30 @@ describe('generateFromGroq transient-failure handling', () => { }); }); + it('throws ModelUnavailableError when all retries fail on a 429', async () => { + fetchMock.mockImplementation(httpError(429)); + await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); + }); + + it('logs the Groq error code but not the response text or API key', async () => { + fetchMock.mockImplementation(httpError(401)); + await call().catch(() => {}); + const logged = JSON.stringify(silentLogger.error.mock.calls, (_key, value: unknown) => + value instanceof Error ? { message: value.message, ...value } : value, + ); + expect(logged).toContain('invalid_api_key'); + expect(logged).not.toContain('Invalid API Key'); + expect(logged).not.toContain('test-key'); + }); + it('treats exhausted empty responses as model unavailability', async () => { - vi.mocked(axios.post).mockResolvedValue({ - data: { + fetchMock.mockImplementation( + jsonResponse({ model: 'test-model', choices: [{ message: { content: ' ' } }], usage: { completion_tokens: 0 }, - }, - }); + }), + ); await expect(call()).rejects.toBeInstanceOf(ModelUnavailableError); expect(silentLogger.error).not.toHaveBeenCalled(); diff --git a/src/lib/__tests__/groqClient.test.ts b/src/lib/__tests__/groqClient.test.ts index eaec618..86ef58f 100644 --- a/src/lib/__tests__/groqClient.test.ts +++ b/src/lib/__tests__/groqClient.test.ts @@ -1,6 +1,4 @@ -import axios, { AxiosError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { GroqApiError, toSafeError } from '../../errors/groqApiError'; vi.mock('../../config/env', () => ({ GROQ_API_KEY: 'test-key', @@ -18,13 +16,8 @@ vi.mock('../../config/env', () => ({ MODEL_CB_COOLDOWN_MS: 30000, })); -vi.mock('axios', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - default: { ...actual.default, post: vi.fn() }, - }; -}); +const fetchMock = vi.fn(); +vi.stubGlobal('fetch', fetchMock); const sentry = vi.hoisted(() => { const attrs: Record = {}; @@ -44,63 +37,6 @@ const sentry = vi.hoisted(() => { vi.mock('@sentry/node', () => ({ startSpan: sentry.startSpan })); -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: {}, - }); -} - -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'); - }); - - 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); - }); - - it('passes non-axios errors through unchanged', () => { - const e = new Error('boom'); - expect(toSafeError(e)).toBe(e); - }); - - it('wraps non-Error values', () => { - const safe = toSafeError('plain string'); - expect(safe).toBeInstanceOf(Error); - expect(safe.message).toBe('plain string'); - }); -}); - -describe('GroqApiError', () => { - it('does not leak the bearer when constructed from a sanitized snapshot', () => { - const safe = toSafeError(makeAxiosError()); - const wrapped = new GroqApiError('Model provider request failed', 502, false, safe, 401); - expect(JSON.stringify({ err: wrapped })).not.toContain(CANARY); - }); -}); - describe('generateFromGroq Sentry span instrumentation', () => { const silentLogger = { info: vi.fn(), @@ -113,23 +49,24 @@ describe('generateFromGroq Sentry span instrumentation', () => { beforeEach(() => { for (const k of Object.keys(sentry.attrs)) delete sentry.attrs[k]; - vi.mocked(axios.post).mockReset(); + fetchMock.mockReset(); }); async function callWithUsage() { - vi.mocked(axios.post).mockResolvedValueOnce({ - data: { - model: 'openai/gpt-oss-120b', - choices: [{ message: { content: 'a hint' } }], - usage: { - prompt_tokens: 2000, - completion_tokens: 200, - total_tokens: 2200, - prompt_tokens_details: { cached_tokens: 1000 }, - }, - }, - // biome-ignore lint: test fixture shape - } as never); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + model: 'openai/gpt-oss-120b', + choices: [{ message: { content: 'a hint' } }], + usage: { + prompt_tokens: 2000, + completion_tokens: 200, + total_tokens: 2200, + prompt_tokens_details: { cached_tokens: 1000 }, + }, + }), + ), + ); const { generateFromGroq } = await import('../groqClient'); return generateFromGroq({ diff --git a/src/lib/groqClient.ts b/src/lib/groqClient.ts index 59132f6..22fc5cd 100644 --- a/src/lib/groqClient.ts +++ b/src/lib/groqClient.ts @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import axios, { AxiosError } from 'axios'; import { GROQ_API_KEY, GROQ_BACKOFF_BASE_MS, @@ -13,7 +12,7 @@ import { MODEL_CB_FAILURES, } from '../config/env'; import { type Logger, rootLogger } from '../config/logger'; -import { GroqApiError, toSafeError } from '../errors/groqApiError'; +import { GroqApiError, HttpError } from '../errors/groqApiError'; import { ModelUnavailableError } from '../errors/modelUnavailableError'; import { CHALLENGE_TYPES, type ChallengeType } from '../types/hint'; @@ -31,6 +30,21 @@ export interface GroqResponse { const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; +interface GroqErrorBody { + error?: { code?: string }; +} + +interface ChatCompletion { + model?: string; + choices?: { message?: { content?: unknown } }[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: { cached_tokens?: number }; + }; +} + export function selectModel(challengeType?: ChallengeType): string { if (!challengeType) { return GROQ_MODEL; @@ -107,9 +121,13 @@ async function makeGroqApiCall( }, }, async (span) => { - const res = await axios.post( - GROQ_API_URL, - { + const res = await fetch(GROQ_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${GROQ_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: requestModel, messages: [ { role: 'system', content: systemPrompt }, @@ -117,17 +135,15 @@ async function makeGroqApiCall( ], max_tokens: maxTokens, temperature: 0.5, - }, - { - headers: { - Authorization: `Bearer ${GROQ_API_KEY}`, - 'Content-Type': 'application/json', - }, - timeout: GROQ_TIMEOUT_MS(), - }, - ); + }), + signal: AbortSignal.timeout(GROQ_TIMEOUT_MS()), + }); + if (!res.ok) { + const body = (await res.json().catch(() => undefined)) as GroqErrorBody | undefined; + throw new HttpError(res.status, body?.error?.code); + } - const data = res.data; + const data = (await res.json()) as ChatCompletion; const content = data.choices?.[0]?.message?.content; const hint = typeof content === 'string' ? content.trim() : ''; const model_used = data.model || GROQ_MODEL; @@ -176,18 +192,17 @@ async function makeGroqApiCall( }, ); } 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) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (lastError instanceof HttpError && lastError.status < 500 && lastError.status !== 429) { handleNonRetryableError(lastError, logger); - throw createGroqError(lastError, status, retryable); + throw new GroqApiError( + 'Model provider request failed', + 502, + false, + lastError, + lastError.status, + ); } logger.warn({ attempt, maxRetries, err: lastError }, 'groq request failed'); @@ -285,24 +300,6 @@ export async function generateFromGroq(options: GroqRequestOptions): Promise= 500 || status === 429) return true; - - // Don't retry on client errors (4xx) except rate limit (429) - if (status >= 400 && status < 500 && status !== 429) return false; - } - - // Retry on timeout and network errors - if (err.message.includes('timeout') || err.message.includes('ECONN')) return true; - - // Default to retry for unknown errors - return true; -} - function handleNonRetryableError(err: Error, logger: Logger): void { logger.error({ err }, 'non-retryable groq error'); @@ -330,16 +327,4 @@ function handleAllRetriesFailed( } } -function createGroqError(err: Error, axiosStatus: number | undefined, isRetryable: boolean): Error { - if (axiosStatus !== undefined) { - return new GroqApiError('Model provider request failed', 502, isRetryable, err, axiosStatus); - } - - if (isRetryable) { - return new GroqApiError(`Groq request error: ${err.message}`, 503, true, err); - } - - return err; -} - export default generateFromGroq; diff --git a/src/lib/rateLimiter.ts b/src/lib/rateLimiter.ts index 148cab5..997637b 100644 --- a/src/lib/rateLimiter.ts +++ b/src/lib/rateLimiter.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import type Redis from 'ioredis'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { GLOBAL_LIMIT, PER_USER_LIMIT } from '../config/env'; -import { toSafeError } from '../errors/groqApiError'; import { rootLogger } from '../config/logger'; import type { HintRequestBody } from '../types/hint'; @@ -122,10 +121,7 @@ export function rateLimiterHook(opts: RateLimiterOptions) { reply.header('X-RateLimit-Limit', String(perUserCap)); reply.header('X-RateLimit-Remaining', String(userRemaining)); } catch (err: unknown) { - request.log.warn( - { err: toSafeError(err) }, - 'rate limiter error, allowing request through (fail-open)', - ); + request.log.warn({ err }, 'rate limiter error, allowing request through (fail-open)'); // Allow request through if Redis or logic fails } }; diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index fd9d39e..7817614 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,15 +1,10 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; -import { toSafeError } from '../errors/groqApiError'; import type { ApiError } from '../types/api'; export function errorHandler(err: ApiError, req: FastifyRequest, reply: FastifyReply) { const statusSource = err.status ?? err.statusCode; const status = statusSource && Number.isInteger(statusSource) ? statusSource : 500; - // Defense-in-depth: strip axios internals before logging. groqClient - // already sanitizes inside its own catch, but if any future caller - // throws a raw AxiosError up to Fastify, this is the final sink before - // the bearer would otherwise reach stdout + Sentry. - req.log[status >= 500 ? 'error' : 'warn']({ err: toSafeError(err) }, 'request failed'); + req.log[status >= 500 ? 'error' : 'warn']({ err }, 'request failed'); reply.status(status).send({ message: err.message || 'Internal Server Error', status }); } diff --git a/src/routes/health.ts b/src/routes/health.ts index 5a98617..ad9adaa 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import type { FastifyInstance } from 'fastify'; import { BUILD_VERSION, ENABLE_EXTENDED_HEALTH, GROQ_API_KEY } from '../config/env'; @@ -34,13 +33,16 @@ 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) { + await r.body?.cancel(); + throw new Error(`HTTP ${r.status}`); + } + const body = (await r.json()) as { data?: unknown[] }; + return { groq: 'ok', models_count: body.data?.length || 0 }; } catch (e: unknown) { return { groq: 'error', error: e instanceof Error ? e.message : String(e) }; }