← back to Commercialrealestate
enrich endpoints: Cody Cycle-2 hardening (timeout + address gate + LRU cache)
2d61ca790f608052d7874d30d3e2e704bd80932f · 2026-08-06 17:03:49 -0700 · Steve Abrams
- getJson: 8s timeout + req.destroy on timeout -> fail fast with 502 instead of
hanging Express forever on a slow gov ArcGIS (the HIGH fix).
- assessorByAddress: strip street to [A-Z0-9 ] (kills ArcGIS-SQL injection chars)
+ require a house number OR >=4-char street term, so q='st' can't scan the
2.4M-row roll.
- serve.js property cache: evict oldest ~10% instead of clear()-all (no cold-miss
thundering herd at the 2000 ceiling).
All verified: valid AIN/address resolve, broad q rejected, endpoint healthy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M scripts/enrich-property.jsM scripts/serve.js
Diff
commit 2d61ca790f608052d7874d30d3e2e704bd80932f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 6 17:03:49 2026 -0700
enrich endpoints: Cody Cycle-2 hardening (timeout + address gate + LRU cache)
- getJson: 8s timeout + req.destroy on timeout -> fail fast with 502 instead of
hanging Express forever on a slow gov ArcGIS (the HIGH fix).
- assessorByAddress: strip street to [A-Z0-9 ] (kills ArcGIS-SQL injection chars)
+ require a house number OR >=4-char street term, so q='st' can't scan the
2.4M-row roll.
- serve.js property cache: evict oldest ~10% instead of clear()-all (no cold-miss
thundering herd at the 2000 ceiling).
All verified: valid AIN/address resolve, broad q rejected, endpoint healthy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/enrich-property.js | 15 ++++++++++++---
scripts/serve.js | 4 +++-
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/scripts/enrich-property.js b/scripts/enrich-property.js
index 13888b8..9b9258b 100644
--- a/scripts/enrich-property.js
+++ b/scripts/enrich-property.js
@@ -26,10 +26,15 @@ const ASSESSOR = 'https://services.arcgis.com/RmCCgQtiZLDCtblq/arcgis/rest/servi
function getJson(url) {
return new Promise((resolve, reject) => {
- https.get(url, { headers: { 'User-Agent': 'CRCP-enrich/1.0 (public-records)' } }, res => {
+ // 8s timeout — public gov ArcGIS hangs under load; without this a slow upstream would hang the
+ // Express request forever (Cody Cycle-2 HIGH). https.get's `timeout` fires the event but does
+ // NOT auto-abort, so destroy the request on timeout to fail fast with a 502.
+ const req = https.get(url, { headers: { 'User-Agent': 'CRCP-enrich/1.0 (public-records)' }, timeout: 8000 }, res => {
if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } });
- }).on('error', reject);
+ });
+ req.on('timeout', () => req.destroy(new Error('assessor timeout')));
+ req.on('error', reject);
});
}
@@ -82,7 +87,11 @@ async function assessorByAin(ain) {
async function assessorByAddress(addr, limit = 8) {
const m = String(addr).trim().match(/^(\d+)\s+(.*)$/);
const house = m ? m[1] : null;
- const street = (m ? m[2] : addr).toUpperCase().replace(/'/g, "''");
+ // Strip to letters/digits/space only — removes ArcGIS-SQL injection chars (quotes, --, ;) AND
+ // makes the single-quote-escape moot (Cody Cycle-2). Then guard against roll-wide '%%' scans:
+ // require a house number OR a street term of >=4 chars before hitting the 2.4M-row service.
+ const street = (m ? m[2] : addr).toUpperCase().replace(/[^A-Z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
+ if (!house && street.length < 4) return [];
let where = `UPPER(SitusStreet) LIKE '%${street}%'`;
if (house) where = `SitusHouseNo='${house}' AND ${where}`;
const url = `${ASSESSOR}?where=${encodeURIComponent(where)}&outFields=*&f=json&resultRecordCount=${limit * 5}`;
diff --git a/scripts/serve.js b/scripts/serve.js
index 423a6c5..421fa27 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -819,7 +819,9 @@ async function _cachedEnrich(key, fn) {
if (hit && (Date.now() - hit.at) < _propTTL) return hit.data;
const data = await fn();
_propCache.set(key, { at: Date.now(), data });
- if (_propCache.size > 2000) _propCache.clear(); // bounded
+ // Evict the oldest ~10% (Map preserves insertion order) instead of wiping all — avoids the
+ // thundering-herd of cold misses a full clear() would cause at the ceiling (Cody Cycle-2).
+ if (_propCache.size > 2000) { let n = 200; for (const k of _propCache.keys()) { if (n-- <= 0) break; _propCache.delete(k); } }
return data;
}
app.get('/api/property/:ain(\\d+)', async (req, res) => { // digit-constrained so /by-address isn't captured
← 8e33148 DSCR Qualifier: add vacancy % + interest-only underwriting l
·
back to Commercialrealestate
·
deals-flow: property-detail drill — click a county deal -> i e087624 →