dde272c4b8
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
292 lines
8.6 KiB
TypeScript
292 lines
8.6 KiB
TypeScript
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
|
|
|
import * as schemas from '../../schemas.js';
|
|
import { SOCRATES_API_KEY, SOCRATES_ENDPOINT } from '../../utils/env.js';
|
|
|
|
const DAILY_LIMITS = { donor: 10, nonDonor: 3 } as const;
|
|
|
|
function getDailyLimit(isDonating: boolean): number {
|
|
return isDonating ? DAILY_LIMITS.donor : DAILY_LIMITS.nonDonor;
|
|
}
|
|
|
|
const NETWORK_ERROR_CODES = new Set([
|
|
'ENOTFOUND',
|
|
'ECONNREFUSED',
|
|
'ECONNRESET',
|
|
'ETIMEDOUT',
|
|
'EAI_AGAIN'
|
|
]);
|
|
|
|
function isFetchNetworkError(error: unknown): boolean {
|
|
if (!(error instanceof TypeError)) {
|
|
return false;
|
|
}
|
|
const cause = (error as { cause?: unknown }).cause;
|
|
const code =
|
|
cause && typeof cause === 'object' && 'code' in cause
|
|
? (cause as { code?: unknown }).code
|
|
: undefined;
|
|
if (typeof code === 'string') {
|
|
return code.startsWith('UND_ERR_') || NETWORK_ERROR_CODES.has(code);
|
|
}
|
|
return error.message === 'fetch failed';
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param fastify The Fastify instance.
|
|
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
|
* @param done The callback to signal that the plugin is ready.
|
|
*/
|
|
export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
|
fastify,
|
|
_options,
|
|
done
|
|
) => {
|
|
// Socrates plugin
|
|
fastify.put(
|
|
'/socrates/get-hint',
|
|
{
|
|
schema: schemas.askSocrates,
|
|
errorHandler(error, req, reply) {
|
|
if (error.validation) {
|
|
void reply.status(400).send({
|
|
error: 'socrates-invalid-request',
|
|
type: 'info',
|
|
attempts: 0,
|
|
limit: 0
|
|
});
|
|
} else {
|
|
fastify.errorHandler(error, req, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
if (!req.user || req.user.socrates === false) {
|
|
return reply.status(403).send({
|
|
error: 'socrates-no-access',
|
|
type: 'danger',
|
|
attempts: 0,
|
|
limit: 0
|
|
});
|
|
}
|
|
|
|
const limit = getDailyLimit(req.user.isDonating);
|
|
const donorStatus = req.user.isDonating ? 'donor' : 'non-donor';
|
|
const now = new Date();
|
|
const todayUTC = new Date(
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
);
|
|
|
|
const existing = await fastify.prisma.socratesUsage.findUnique({
|
|
where: {
|
|
userId_date: { userId: req.user.id, date: todayUTC }
|
|
}
|
|
});
|
|
|
|
if (existing && existing.count >= limit) {
|
|
fastify.Sentry?.metrics?.count('socrates.rate_limit_hit', 1, {
|
|
attributes: { source: 'local', donorStatus }
|
|
});
|
|
return reply.status(429).send({
|
|
error: 'socrates-daily-limit',
|
|
type: 'info',
|
|
attempts: limit,
|
|
limit
|
|
});
|
|
}
|
|
|
|
const usage = await fastify.prisma.socratesUsage.upsert({
|
|
where: {
|
|
userId_date: { userId: req.user.id, date: todayUTC }
|
|
},
|
|
create: {
|
|
userId: req.user.id,
|
|
date: todayUTC,
|
|
count: 1
|
|
},
|
|
update: {
|
|
count: { increment: 1 }
|
|
}
|
|
});
|
|
|
|
const attempts = usage.count;
|
|
|
|
const rollbackUsage = async () => {
|
|
await fastify.prisma.socratesUsage.update({
|
|
where: {
|
|
userId_date: { userId: req.user!.id, date: todayUTC }
|
|
},
|
|
data: { count: { decrement: 1 } }
|
|
});
|
|
};
|
|
|
|
const upstreamFetchStart = performance.now();
|
|
|
|
try {
|
|
const response = await fetch(`${SOCRATES_ENDPOINT}/hint`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': SOCRATES_API_KEY
|
|
},
|
|
body: JSON.stringify({
|
|
description: req.body.description,
|
|
userInput: req.body.userInput,
|
|
seed: req.body.seed,
|
|
hints: req.body.hints,
|
|
userId: req.user.id
|
|
})
|
|
});
|
|
|
|
fastify.Sentry?.metrics?.distribution(
|
|
'socrates.upstream_latency_ms',
|
|
performance.now() - upstreamFetchStart,
|
|
{ unit: 'millisecond', attributes: { result: 'success' } }
|
|
);
|
|
|
|
const responseText = await response.text();
|
|
|
|
if (!response.ok) {
|
|
req.log.error(
|
|
{
|
|
status: response.status,
|
|
upstreamBody: responseText.slice(0, 500)
|
|
},
|
|
'Socrates API returned an error response.'
|
|
);
|
|
|
|
await rollbackUsage();
|
|
|
|
if (response.status === 429) {
|
|
fastify.Sentry?.metrics?.count('socrates.rate_limit_hit', 1, {
|
|
attributes: { source: 'upstream', donorStatus }
|
|
});
|
|
return reply.status(429).send({
|
|
error: 'socrates-rate-limit',
|
|
type: 'info',
|
|
attempts: attempts - 1,
|
|
limit
|
|
});
|
|
}
|
|
|
|
if (response.status === 400) {
|
|
let upstreamMessage: string | undefined;
|
|
try {
|
|
const parsed = responseText
|
|
? (JSON.parse(responseText) as { error?: string })
|
|
: null;
|
|
upstreamMessage = parsed?.error;
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
|
attributes: { reason: 'bad_status' }
|
|
});
|
|
return reply.status(400).send({
|
|
error: upstreamMessage || 'socrates-unable-to-generate',
|
|
type: 'info',
|
|
attempts: attempts - 1,
|
|
limit
|
|
});
|
|
}
|
|
|
|
fastify.Sentry?.captureException(
|
|
new Error(`Socrates API returned status ${response.status}`)
|
|
);
|
|
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
|
attributes: { reason: 'bad_status' }
|
|
});
|
|
return reply.status(500).send({
|
|
error: 'socrates-unavailable',
|
|
type: 'danger',
|
|
attempts: attempts - 1,
|
|
limit
|
|
});
|
|
}
|
|
|
|
let payload: unknown;
|
|
try {
|
|
payload = responseText ? JSON.parse(responseText) : null;
|
|
} catch (error) {
|
|
fastify.Sentry?.captureException(error);
|
|
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
|
attributes: { reason: 'invalid_response' }
|
|
});
|
|
req.log.error(
|
|
{ err: error },
|
|
'Failed to parse Socrates API response.'
|
|
);
|
|
await rollbackUsage();
|
|
return reply.status(500).send({
|
|
error: 'socrates-unavailable',
|
|
type: 'danger',
|
|
attempts: attempts - 1,
|
|
limit
|
|
});
|
|
}
|
|
|
|
if (
|
|
!payload ||
|
|
typeof payload !== 'object' ||
|
|
typeof (payload as { hint?: unknown }).hint !== 'string'
|
|
) {
|
|
fastify.Sentry?.captureException(
|
|
new Error('Socrates API did not return a hint')
|
|
);
|
|
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
|
attributes: { reason: 'missing_hint' }
|
|
});
|
|
req.log.error(
|
|
{
|
|
payloadType: payload === null ? 'null' : typeof payload,
|
|
hintType: typeof (payload as { hint?: unknown } | null)?.hint
|
|
},
|
|
'Socrates API did not return a hint.'
|
|
);
|
|
await rollbackUsage();
|
|
return reply.status(500).send({
|
|
error: 'socrates-unavailable',
|
|
type: 'danger',
|
|
attempts: attempts - 1,
|
|
limit
|
|
});
|
|
}
|
|
|
|
const { hint } = payload as { hint: string };
|
|
|
|
fastify.Sentry?.metrics?.count('socrates.hint_granted', 1, {
|
|
attributes: { donorStatus }
|
|
});
|
|
return { hint, attempts, limit } as const;
|
|
} catch (error) {
|
|
fastify.Sentry?.metrics?.distribution(
|
|
'socrates.upstream_latency_ms',
|
|
performance.now() - upstreamFetchStart,
|
|
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
|
);
|
|
if (!isFetchNetworkError(error)) {
|
|
fastify.Sentry?.captureException(error);
|
|
}
|
|
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
|
attributes: {
|
|
reason: isFetchNetworkError(error) ? 'network' : 'exception'
|
|
}
|
|
});
|
|
req.log.error(
|
|
{ err: error },
|
|
'Failed to fetch hint from Socrates API.'
|
|
);
|
|
await rollbackUsage();
|
|
return reply.status(500).send({
|
|
error: 'socrates-unavailable',
|
|
type: 'danger',
|
|
attempts: attempts - 1,
|
|
limit
|
|
});
|
|
}
|
|
}
|
|
);
|
|
done();
|
|
};
|