Skip to content

Commit 2e9de5e

Browse files
ekremneyclaude
andauthored
fix(tier-client): avoid 414 URI Too Large on orgs with many enrollments (#1390)
## Summary - **Root cause**: After migrating from DynamoDB to PostgREST, `batchGetByKeys` generates GET requests with `?id=in.(uuid1,uuid2,...)` in the URL. For orgs with hundreds of site enrollments, this exceeds HTTP URL length limits causing **414 Request-URI Too Large**. - **`getAllEnrollment()`**: When a specific site is provided, skips batch fetch entirely and uses a single `findById` call. For org-only path, chunks `batchGetByKeys` into groups of 50 IDs (~1,800 chars per chunk, well under 8KB limit). - **`getFirstEnrollment()`**: Now standalone (no longer calls `getAllEnrollment`). Site-specific path does in-memory match and returns `this.site` directly. Org-only path iterates enrollments with `findById` one at a time with early exit on first match. ## Test plan - [x] All 70 unit tests pass - [x] 100% code coverage (statements, branches, functions, lines) - [x] ESLint clean - [ ] Deploy to dev and test `sites-resolve` endpoint with AEM Reference Demo org (the org that was hitting 414) - [ ] Verify login flow works for orgs with many enrollments 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3f0d583 commit 2e9de5e

File tree

2 files changed

+192
-95
lines changed

2 files changed

+192
-95
lines changed

packages/spacecat-shared-tier-client/src/tier-client.js

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -239,38 +239,54 @@ class TierClient {
239239
return { entitlement, enrollments: [] };
240240
}
241241

242-
// Fetch all sites using batchGetByKeys
242+
// When a specific site is provided, skip batch fetch entirely.
243+
// Just filter enrollments by site ID and verify org ownership with a single lookup.
244+
if (this.site) {
245+
const targetSiteId = this.site.getId();
246+
const matchingEnrollments = allEnrollments.filter(
247+
(se) => se.getSiteId() === targetSiteId,
248+
);
249+
250+
if (matchingEnrollments.length === 0) {
251+
return { entitlement, enrollments: [] };
252+
}
253+
254+
const site = await this.Site.findById(targetSiteId);
255+
if (!site || site.getOrganizationId() !== orgId) {
256+
return { entitlement, enrollments: [] };
257+
}
258+
259+
return { entitlement, enrollments: matchingEnrollments };
260+
}
261+
262+
// Org-only path: fetch sites in chunks to avoid 414 URI Too Large.
263+
// PostgREST uses GET with ?id=in.(...) which has URL length limits.
264+
const CHUNK_SIZE = 50;
243265
const siteKeys = allEnrollments.map((enrollment) => ({ siteId: enrollment.getSiteId() }));
244-
const sitesResult = await this.Site.batchGetByKeys(siteKeys);
245-
const sitesMap = new Map(sitesResult.data.map((site) => [site.getId(), site]));
266+
const sitesMap = new Map();
267+
268+
for (let i = 0; i < siteKeys.length; i += CHUNK_SIZE) {
269+
const chunk = siteKeys.slice(i, i + CHUNK_SIZE);
270+
// eslint-disable-next-line no-await-in-loop
271+
const sitesResult = await this.Site.batchGetByKeys(chunk);
272+
for (const site of sitesResult.data) {
273+
sitesMap.set(site.getId(), site);
274+
}
275+
}
246276

247277
// Filter enrollments where site's orgId matches the entitlement's orgId
248278
const validEnrollments = [];
249279

250280
for (const enrollment of allEnrollments) {
251281
const site = sitesMap.get(enrollment.getSiteId());
252282
if (!site) {
253-
// Site not found, log warning and skip
254283
this.log.warn(`Site not found for enrollment ${enrollment.getId()} with siteId ${enrollment.getSiteId()}`);
255-
} else {
256-
const siteOrgId = site.getOrganizationId();
257-
if (siteOrgId === orgId) {
258-
validEnrollments.push(enrollment);
259-
}
284+
} else if (site.getOrganizationId() === orgId) {
285+
validEnrollments.push(enrollment);
260286
}
261287
}
262288

263-
if (this.site) {
264-
// Return site enrollments matching the entitlement and site
265-
const siteId = this.site.getId();
266-
const matchingEnrollments = validEnrollments.filter(
267-
(se) => se.getSiteId() === siteId,
268-
);
269-
return { entitlement, enrollments: matchingEnrollments };
270-
} else {
271-
// Return all valid enrollments for the entitlement
272-
return { entitlement, enrollments: validEnrollments };
273-
}
289+
return { entitlement, enrollments: validEnrollments };
274290
} catch (error) {
275291
this.log.error(`Error getting all enrollments: ${error.message}`);
276292
throw error;
@@ -279,28 +295,51 @@ class TierClient {
279295

280296
/**
281297
* Gets the first enrollment and its site, filtered by productCode.
282-
* - If site is provided: returns site enrollment for the entitlement matching productCode
283-
* - If org-only: returns first site enrollment for the entitlement matching productCode
298+
* - If site is provided: finds matching enrollment and returns this.site directly
299+
* - If org-only: iterates enrollments, fetches sites one at a time, returns first org match
284300
* @returns {Promise<object>} Object with entitlement, enrollment, and site.
285301
*/
286302
async getFirstEnrollment() {
287303
try {
288-
const { entitlement, enrollments } = await this.getAllEnrollment();
304+
const orgId = this.organization.getId();
305+
const entitlement = await this.Entitlement
306+
.findByOrganizationIdAndProductCode(orgId, this.productCode);
307+
308+
if (!entitlement) {
309+
return { entitlement: null, enrollment: null, site: null };
310+
}
311+
312+
const allEnrollments = await this.SiteEnrollment.allByEntitlementId(entitlement.getId());
289313

290-
if (!entitlement || !enrollments?.length) {
314+
if (!allEnrollments || allEnrollments.length === 0) {
291315
return { entitlement: null, enrollment: null, site: null };
292316
}
293317

294-
const firstEnrollment = enrollments[0];
295-
const enrollmentSiteId = firstEnrollment.getSiteId();
296-
const site = await this.Site.findById(enrollmentSiteId);
318+
// When a specific site is set, find its enrollment in memory — no fetch needed.
319+
if (this.site) {
320+
const targetSiteId = this.site.getId();
321+
const matchingEnrollment = allEnrollments.find(
322+
(se) => se.getSiteId() === targetSiteId,
323+
);
324+
if (matchingEnrollment) {
325+
return { entitlement, enrollment: matchingEnrollment, site: this.site };
326+
}
327+
return { entitlement: null, enrollment: null, site: null };
328+
}
297329

298-
if (!site) {
299-
this.log.warn(`Site not found for enrollment ${firstEnrollment.getId()} with site ID ${enrollmentSiteId}`);
300-
return { entitlement, enrollment: firstEnrollment, site: null };
330+
// Org-only: iterate enrollments, fetch site one at a time, return first org match.
331+
// This avoids batch-fetching all sites (which causes 414 on large sets).
332+
for (const enrollment of allEnrollments) {
333+
// eslint-disable-next-line no-await-in-loop
334+
const site = await this.Site.findById(enrollment.getSiteId());
335+
if (!site) {
336+
this.log.warn(`Site not found for enrollment ${enrollment.getId()} with siteId ${enrollment.getSiteId()}`);
337+
} else if (site.getOrganizationId() === orgId) {
338+
return { entitlement, enrollment, site };
339+
}
301340
}
302341

303-
return { entitlement, enrollment: firstEnrollment, site };
342+
return { entitlement: null, enrollment: null, site: null };
304343
} catch (error) {
305344
this.log.error(`Error getting first enrollment: ${error.message}`);
306345
throw error;

0 commit comments

Comments
 (0)