Skip to content

Commit 9b43944

Browse files
authored
fix: add embed retry + graceful fallback + proactive cache warming (#77)
- AiService.embedWithRetry(): up to 3 attempts with 1s/2s/4s exponential backoff for transient network errors (Premature close, ECONNRESET, etc.) - FeedService: catch embed failures and return empty feed instead of 500 - UsersService.saveInterests(): fire-and-forget warmEmbedding() after tags are saved so the cache is always warm before the next /feed request - UsersModule: import AiModule so AiService can be injected into UsersService Fixes: feed returning 500 when user edits interests and /feed is called before the OpenAI embedding succeeds on a cold Render container
1 parent fddf526 commit 9b43944

4 files changed

Lines changed: 90 additions & 22 deletions

File tree

apps/api/src/ai/ai.service.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,43 @@ export class AiService {
6161
}
6262

6363
async embed(text: string): Promise<number[]> {
64-
const response = await this.client.embeddings.create({
65-
model: 'text-embedding-3-small',
66-
input: text,
67-
});
64+
return this.embedWithRetry(text, 3);
65+
}
66+
67+
private async embedWithRetry(
68+
text: string,
69+
maxAttempts: number,
70+
attempt = 1,
71+
): Promise<number[]> {
72+
try {
73+
const response = await this.client.embeddings.create({
74+
model: 'text-embedding-3-small',
75+
input: text,
76+
});
77+
const embedding = response.data[0]?.embedding;
78+
if (!embedding) throw new Error('OpenAI embeddings returned no data');
79+
return embedding;
80+
} catch (err: unknown) {
81+
const msg = err instanceof Error ? err.message : String(err);
82+
const isTransient =
83+
/premature close|connection error|econnreset|econnrefused|socket hang up|network/i.test(
84+
msg,
85+
);
6886

69-
const embedding = response.data[0]?.embedding;
70-
if (!embedding) throw new Error('OpenAI embeddings returned no data');
71-
return embedding;
87+
if (isTransient && attempt < maxAttempts) {
88+
const delayMs = Math.pow(2, attempt - 1) * 1000; // 1 s, 2 s, 4 s
89+
this.logger.warn(
90+
`embed() attempt ${attempt} failed (${msg}). Retrying in ${delayMs}ms…`,
91+
);
92+
await new Promise((r) => setTimeout(r, delayMs));
93+
return this.embedWithRetry(text, maxAttempts, attempt + 1);
94+
}
95+
96+
this.logger.error(
97+
`embed() failed after ${attempt} attempt(s): ${msg}`,
98+
);
99+
throw err;
100+
}
72101
}
73102

74103
async processUnsummarized(

apps/api/src/feed/feed.service.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,19 @@ export class FeedService {
8585
this.logger.log(
8686
`Generating embedding for user ${userId} | query: "${queryText}"`,
8787
);
88-
embedding = await this.aiService.embed(queryText);
89-
if (interestRow.length > 0) {
90-
await this.db
91-
.update(userInterests)
92-
.set({ queryEmbedding: embedding })
93-
.where(eq(userInterests.userId, userId));
88+
try {
89+
embedding = await this.aiService.embed(queryText);
90+
if (interestRow.length > 0) {
91+
await this.db
92+
.update(userInterests)
93+
.set({ queryEmbedding: embedding })
94+
.where(eq(userInterests.userId, userId));
95+
}
96+
} catch (err) {
97+
this.logger.error(
98+
`Failed to generate embedding for user ${userId} — returning empty feed: ${err}`,
99+
);
100+
return { hasMatches: false, articles: [], fallback: [] };
94101
}
95102
}
96103

@@ -227,12 +234,19 @@ export class FeedService {
227234
this.logger.log(
228235
`Generating embedding for user ${userId} (debug) | query: "${queryText}"`,
229236
);
230-
embedding = await this.aiService.embed(queryText);
231-
if (interestRow.length > 0) {
232-
await this.db
233-
.update(userInterests)
234-
.set({ queryEmbedding: embedding })
235-
.where(eq(userInterests.userId, userId));
237+
try {
238+
embedding = await this.aiService.embed(queryText);
239+
if (interestRow.length > 0) {
240+
await this.db
241+
.update(userInterests)
242+
.set({ queryEmbedding: embedding })
243+
.where(eq(userInterests.userId, userId));
244+
}
245+
} catch (err) {
246+
this.logger.error(
247+
`Failed to generate embedding for user ${userId} (debug): ${err}`,
248+
);
249+
return { queryText, articles: [] };
236250
}
237251
}
238252

apps/api/src/users/users.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Module, forwardRef } from '@nestjs/common';
22
import { UsersService } from './users.service';
33
import { UsersController } from './users.controller';
44
import { AuthModule } from '../auth/auth.module';
5+
import { AiModule } from '../ai/ai.module';
56

67
@Module({
7-
imports: [forwardRef(() => AuthModule)],
8+
imports: [forwardRef(() => AuthModule), AiModule],
89
providers: [UsersService],
910
controllers: [UsersController],
1011
exports: [UsersService],

apps/api/src/users/users.service.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import { Inject, Injectable } from '@nestjs/common';
1+
import { Inject, Injectable, Logger } from '@nestjs/common';
22
import { eq } from 'drizzle-orm';
33
import { DRIZZLE } from '../db/drizzle.provider';
44
import type { DrizzleDB } from '../db/drizzle.provider';
55
import { users, userInterests, User, NewUser } from '../db/schema';
6+
import { AiService } from '../ai/ai.service';
67

78
@Injectable()
89
export class UsersService {
9-
constructor(@Inject(DRIZZLE) private db: DrizzleDB) {}
10+
private readonly logger = new Logger(UsersService.name);
11+
12+
constructor(
13+
@Inject(DRIZZLE) private db: DrizzleDB,
14+
private readonly aiService: AiService,
15+
) {}
1016

1117
async upsert(googleProfile: {
1218
id: string;
@@ -78,6 +84,24 @@ export class UsersService {
7884
target: userInterests.userId,
7985
set: { tags, queryEmbedding: null },
8086
});
87+
88+
// Fire-and-forget: warm the embedding cache so the next /feed request
89+
// doesn't have to call OpenAI cold (which risks a transient 500).
90+
this.warmEmbedding(userId, tags).catch((err) =>
91+
this.logger.warn(`Background embed warming failed for user ${userId}: ${err}`),
92+
);
93+
}
94+
95+
private async warmEmbedding(userId: string, tags: string[]): Promise<void> {
96+
if (tags.length === 0) return;
97+
const queryText = `software engineering articles about ${tags.join(', ')} for developers`;
98+
this.logger.log(`Warming embedding for user ${userId} after interests update`);
99+
const embedding = await this.aiService.embed(queryText);
100+
await this.db
101+
.update(userInterests)
102+
.set({ queryEmbedding: embedding })
103+
.where(eq(userInterests.userId, userId));
104+
this.logger.log(`Embedding warmed for user ${userId}`);
81105
}
82106

83107
async getInterests(userId: string): Promise<{ tags: string[] }> {

0 commit comments

Comments
 (0)