[object Object]

← 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

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 →