Skip to content

Commit b84c3e3

Browse files
committed
Use separate endpoint for delivery previews
1 parent dae978b commit b84c3e3

9 files changed

Lines changed: 278 additions & 88 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Type } from 'class-transformer';
2+
import { IsNumber, IsOptional, IsString, Validate } from 'class-validator';
3+
import { HttpValidator } from './fetch-feed.dto';
4+
5+
export class FetchFeedDeliveryPreviewDto {
6+
@Validate(HttpValidator)
7+
url!: string;
8+
9+
@IsString()
10+
@IsOptional()
11+
lookupKey?: string;
12+
13+
@IsNumber()
14+
@IsOptional()
15+
@Type(() => Number)
16+
stalenessThresholdSeconds?: number;
17+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './fetch-feed.dto';
22
export * from './fetch-feed-details.dto';
3+
export * from './fetch-feed-delivery-preview.dto';
34
export * from './get-feed-requests-input.dto';
45
export * from './get-feed-requests-output.dto';

services/feed-requests/src/feed-fetcher/feed-fetcher.controller.ts

Lines changed: 118 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { RequestStatus } from './constants';
1515
import {
1616
FetchFeedDto,
1717
FetchFeedDetailsDto,
18+
FetchFeedDeliveryPreviewDto,
1819
GetFeedRequestsInputDto,
1920
GetFeedRequestsOutputDto,
2021
} from './dto';
@@ -103,6 +104,117 @@ export class FeedFetcherController {
103104
return this.getLatestRequest(data);
104105
}
105106

107+
@Post('feed-requests/delivery-preview')
108+
@UseGuards(ApiGuard)
109+
async fetchFeedDeliveryPreview(
110+
@Body(ValidationPipe) data: FetchFeedDeliveryPreviewDto,
111+
): Promise<FetchFeedDetailsDto> {
112+
const lookupKey = data.lookupKey || data.url;
113+
114+
// 1. Get latest request (any status, including errors)
115+
let latestRequest =
116+
await this.partitionedRequestsStoreService.getLatestRequestAnyStatus(
117+
lookupKey,
118+
);
119+
120+
// 2. Check staleness
121+
const threshold = data.stalenessThresholdSeconds ?? 1800;
122+
const isStale =
123+
!latestRequest ||
124+
dayjs().diff(latestRequest.createdAt, 'second') > threshold;
125+
126+
// 3. If stale, fetch new
127+
if (isStale) {
128+
const { request } = await this.feedFetcherService.fetchAndSaveResponse(
129+
data.url,
130+
{
131+
lookupDetails: data.lookupKey ? { key: data.lookupKey } : undefined,
132+
source: undefined,
133+
},
134+
);
135+
136+
await this.partitionedRequestsStoreService.flushInserts([request]);
137+
138+
// Re-fetch latest
139+
latestRequest =
140+
await this.partitionedRequestsStoreService.getLatestRequestAnyStatus(
141+
lookupKey,
142+
);
143+
}
144+
145+
// 4. Map to response
146+
if (!latestRequest) {
147+
return { requestStatus: 'FETCH_ERROR' as const };
148+
}
149+
150+
const decodedBody = await this.feedFetcherService.decodeResponseContent(
151+
latestRequest.response?.content,
152+
);
153+
154+
return this.mapStatusToResponse(
155+
latestRequest.status,
156+
latestRequest.response,
157+
decodedBody,
158+
);
159+
}
160+
161+
/**
162+
* Maps a request status and response to FetchFeedDetailsDto.
163+
* Shared between delivery preview and regular feed request endpoints.
164+
*/
165+
private mapStatusToResponse(
166+
status: RequestStatus,
167+
response: { textHash?: string | null; statusCode: number } | null,
168+
body: string,
169+
): FetchFeedDetailsDto {
170+
if (status === RequestStatus.INVALID_SSL_CERTIFICATE) {
171+
return { requestStatus: 'INVALID_SSL_CERTIFICATE' as const };
172+
}
173+
174+
if (status === RequestStatus.REFUSED_LARGE_FEED) {
175+
return { requestStatus: 'REFUSED_LARGE_FEED' as const };
176+
}
177+
178+
if (status === RequestStatus.FETCH_TIMEOUT) {
179+
return { requestStatus: 'FETCH_TIMEOUT' as const };
180+
}
181+
182+
if (status === RequestStatus.FETCH_ERROR || !response) {
183+
return { requestStatus: 'FETCH_ERROR' as const };
184+
}
185+
186+
if (status === RequestStatus.OK) {
187+
return {
188+
requestStatus: 'SUCCESS' as const,
189+
response: {
190+
hash: response.textHash,
191+
body,
192+
statusCode: response.statusCode,
193+
},
194+
};
195+
}
196+
197+
if (status === RequestStatus.PARSE_ERROR) {
198+
return {
199+
requestStatus: 'PARSE_ERROR' as const,
200+
response: { statusCode: response.statusCode },
201+
};
202+
}
203+
204+
if (status === RequestStatus.INTERNAL_ERROR) {
205+
return { requestStatus: 'INTERNAL_ERROR' as const };
206+
}
207+
208+
if (status === RequestStatus.BAD_STATUS_CODE) {
209+
return {
210+
requestStatus: 'BAD_STATUS_CODE' as const,
211+
response: { statusCode: response.statusCode },
212+
};
213+
}
214+
215+
throw new Error(`Unhandled request status: ${status}`);
216+
}
217+
106218
private async getLatestRequest(
107219
data: FetchFeedDto,
108220
): Promise<FetchFeedDetailsDto> {
@@ -191,9 +303,7 @@ export class FeedFetcherController {
191303
};
192304
}
193305

194-
const latestRequestStatus = latestRequest.request.status;
195-
const latestRequestResponse = latestRequest.request.response;
196-
306+
// Check for hash match before mapping status
197307
if (
198308
data.hashToCompare &&
199309
latestRequest.request.response?.textHash &&
@@ -204,68 +314,10 @@ export class FeedFetcherController {
204314
};
205315
}
206316

207-
if (latestRequestStatus === RequestStatus.INVALID_SSL_CERTIFICATE) {
208-
return {
209-
requestStatus: 'INVALID_SSL_CERTIFICATE' as const,
210-
};
211-
}
212-
213-
if (latestRequestStatus === RequestStatus.REFUSED_LARGE_FEED) {
214-
return {
215-
requestStatus: 'REFUSED_LARGE_FEED' as const,
216-
};
217-
}
218-
219-
if (latestRequestStatus === RequestStatus.FETCH_TIMEOUT) {
220-
return {
221-
requestStatus: 'FETCH_TIMEOUT' as const,
222-
};
223-
}
224-
225-
if (
226-
latestRequestStatus === RequestStatus.FETCH_ERROR ||
227-
!latestRequestResponse
228-
) {
229-
return {
230-
requestStatus: 'FETCH_ERROR' as const,
231-
};
232-
}
233-
234-
if (latestRequestStatus === RequestStatus.OK) {
235-
return {
236-
requestStatus: 'SUCCESS' as const,
237-
response: {
238-
hash: latestRequestResponse.textHash,
239-
body: latestRequest.decodedResponseText as string,
240-
statusCode: latestRequestResponse.statusCode,
241-
},
242-
};
243-
}
244-
245-
if (latestRequestStatus === RequestStatus.PARSE_ERROR) {
246-
return {
247-
requestStatus: 'PARSE_ERROR' as const,
248-
response: {
249-
statusCode: latestRequestResponse.statusCode,
250-
},
251-
};
252-
}
253-
254-
if (latestRequestStatus === RequestStatus.INTERNAL_ERROR) {
255-
return {
256-
requestStatus: 'INTERNAL_ERROR' as const,
257-
};
258-
}
259-
260-
if (latestRequestStatus === RequestStatus.BAD_STATUS_CODE) {
261-
return {
262-
requestStatus: 'BAD_STATUS_CODE' as const,
263-
response: {
264-
statusCode: latestRequestResponse.statusCode,
265-
},
266-
};
267-
}
268-
269-
throw new Error(`Unhandled request status: ${latestRequestStatus}`);
317+
return this.mapStatusToResponse(
318+
latestRequest.request.status,
319+
latestRequest.request.response,
320+
latestRequest.decodedResponseText ?? '',
321+
);
270322
}
271323
}

services/feed-requests/src/feed-fetcher/feed-fetcher.service.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,28 @@ export class FeedFetcherService {
176176
return { request, decodedResponseText: '' };
177177
}
178178

179+
/**
180+
* Decode compressed response content from a request.
181+
* Used by delivery preview to get the body from any request status.
182+
*/
183+
async decodeResponseContent(
184+
compressedContent: string | null | undefined,
185+
): Promise<string> {
186+
if (!compressedContent) {
187+
return '';
188+
}
189+
190+
try {
191+
const decompressed = await inflatePromise(
192+
Buffer.from(compressedContent, 'base64'),
193+
);
194+
195+
return decompressed.toString();
196+
} catch {
197+
return '';
198+
}
199+
}
200+
179201
async fetchAndSaveResponse(
180202
url: string,
181203
options?: {

services/feed-requests/src/partitioned-requests-store/partitioned-requests-store.service.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,32 @@ export default class PartitionedRequestsStoreService {
346346
return this.mapPartitionedRequestToModel(result);
347347
}
348348

349+
/**
350+
* Get the latest request for a lookup key, regardless of status or body presence.
351+
* Used by delivery preview to check staleness including error states.
352+
*/
353+
async getLatestRequestAnyStatus(lookupKey: string): Promise<Request | null> {
354+
const em = this.orm.em.getConnection();
355+
356+
const [result] = await em.execute(
357+
`SELECT req.*, res.content AS response_body_content,
358+
res.content_hash AS response_content_hash
359+
FROM request_partitioned req
360+
LEFT JOIN response_bodies res
361+
ON req.response_body_hash_key = res.hash_key
362+
WHERE req.lookup_key = ?
363+
ORDER BY created_at DESC
364+
LIMIT 1`,
365+
[lookupKey],
366+
);
367+
368+
if (!result) {
369+
return null;
370+
}
371+
372+
return this.mapPartitionedRequestToModel(result);
373+
}
374+
349375
private mapPartitionedRequestToModel(result: any) {
350376
const request: Request = {
351377
id: result.id,

services/user-feeds-next/.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ deploy-api.sh
3333
.DS_Store
3434
*.log
3535
coverage/
36+
**/*.heapsnapshot

services/user-feeds-next/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
3333
# Finder (MacOS) folder config
3434
.DS_Store
3535

36-
deploy*.sh
36+
deploy*.sh
37+
38+
*.heapsnapshot

services/user-feeds-next/src/feed-fetcher/feed-fetcher.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,66 @@ async function handleFetchResponse({
147147
`Unexpected feed request status in response: ${requestStatus}`
148148
);
149149
}
150+
151+
/**
152+
* Fetch feed for delivery preview using the dedicated endpoint.
153+
* This endpoint checks staleness based on ANY request status (including errors),
154+
* preventing duplicate fetches when only error records exist.
155+
*
156+
* Compatible with fetchFeed signature so it can be passed to fetchAndParseFeed.
157+
*/
158+
export async function fetchFeedForDeliveryPreview(
159+
url: string,
160+
options: {
161+
serviceHost: string;
162+
stalenessThresholdSeconds?: number;
163+
lookupDetails?: FeedRequestLookupDetails | null;
164+
// These are accepted for compatibility but not used by this endpoint
165+
executeFetch?: boolean;
166+
executeFetchIfNotInCache?: boolean;
167+
executeFetchIfStale?: boolean;
168+
retries?: number;
169+
hashToCompare?: string;
170+
}
171+
): Promise<FetchFeedResult> {
172+
const serviceHost = options.serviceHost;
173+
let response: Response;
174+
175+
try {
176+
const requestBody = {
177+
url,
178+
lookupKey: options.lookupDetails?.key,
179+
stalenessThresholdSeconds: options.stalenessThresholdSeconds,
180+
};
181+
182+
const endpointUrl = serviceHost.endsWith("/")
183+
? `${serviceHost}delivery-preview`
184+
: `${serviceHost}/delivery-preview`;
185+
186+
response = await pRetry(
187+
async () =>
188+
fetch(endpointUrl, {
189+
method: "POST",
190+
body: JSON.stringify(requestBody),
191+
headers: {
192+
"content-type": "application/json",
193+
accept: "application/json",
194+
"api-key": API_KEY,
195+
},
196+
}),
197+
{
198+
retries: 2,
199+
randomize: true,
200+
}
201+
);
202+
} catch (err) {
203+
throw new FeedRequestNetworkException(
204+
`Failed to execute request to feed requests API: ${(err as Error).message}`
205+
);
206+
}
207+
208+
return handleFetchResponse({
209+
statusCode: response.status,
210+
json: response.json.bind(response),
211+
});
212+
}

0 commit comments

Comments
 (0)