<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nate B</title>
    <description>The latest articles on DEV Community by Nate B (@nate_b_76a98ee76221cdb5bb).</description>
    <link>https://dev.to/nate_b_76a98ee76221cdb5bb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4054038%2F978d5525-7efe-4a9b-aaad-5c51e9ee2eda.jpg</url>
      <title>DEV Community: Nate B</title>
      <link>https://dev.to/nate_b_76a98ee76221cdb5bb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nate_b_76a98ee76221cdb5bb"/>
    <language>en</language>
    <item>
      <title>Seven government recall feeds, and what it takes to make them agree</title>
      <dc:creator>Nate B</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:35:57 +0000</pubDate>
      <link>https://dev.to/nate_b_76a98ee76221cdb5bb/seven-government-recall-feeds-and-what-it-takes-to-make-them-agree-1faf</link>
      <guid>https://dev.to/nate_b_76a98ee76221cdb5bb/seven-government-recall-feeds-and-what-it-takes-to-make-them-agree-1faf</guid>
      <description>&lt;p&gt;Every developed market publishes its product recalls. The EU has Safety Gate, France runs&lt;br&gt;
RappelConso on top of it, the US splits the job across the CPSC, NHTSA and three separate FDA&lt;br&gt;
enforcement databases. All of it is free, all of it is public, and most of it has an API.&lt;/p&gt;

&lt;p&gt;So checking whether a product has been recalled should be easy.&lt;/p&gt;

&lt;p&gt;It is not, and the reasons are more interesting than "the schemas differ." Here is what came out&lt;br&gt;
of normalising 176,000 recalls from seven of these sources into a single queryable corpus.&lt;/p&gt;
&lt;h2&gt;
  
  
  The schemas differ, and that is the easy part
&lt;/h2&gt;

&lt;p&gt;Safety Gate publishes XML per weekly report. RappelConso is an OpenDataSoft instance with a JSON&lt;br&gt;
API. NHTSA ships a ZIP containing a pipe-delimited flat file. openFDA gives you clean JSON with&lt;br&gt;
proper pagination. The CPSC has a REST API that mostly behaves.&lt;/p&gt;

&lt;p&gt;Mapping five shapes onto one schema is a week of tedious work with a clear finish line. You write&lt;br&gt;
a normaliser per source, you write tests, you are done. Nobody's project fails here.&lt;/p&gt;

&lt;p&gt;Where it actually gets hard is that the sources disagree about &lt;strong&gt;what a recall is&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An openFDA enforcement report is one row per &lt;em&gt;product&lt;/em&gt; in a recall event: a single event with&lt;br&gt;
forty affected lot numbers is forty rows sharing an &lt;code&gt;event_id&lt;/code&gt;. A Safety Gate notification is one&lt;br&gt;
row per &lt;em&gt;notification&lt;/em&gt;, which may cover several products from one manufacturer. NHTSA is one row&lt;br&gt;
per &lt;em&gt;campaign&lt;/em&gt;, which may cover multiple vehicle models across several years.&lt;/p&gt;

&lt;p&gt;Deduplicate naively across sources and you undercount, because the same physical hazard&lt;br&gt;
legitimately appears as three different record types. Do not deduplicate at all and a customer&lt;br&gt;
searching a barcode gets forty near-identical hits for one event. Neither is what anybody wants.&lt;/p&gt;

&lt;p&gt;We keep records at source granularity and reconcile at the &lt;em&gt;identifier&lt;/em&gt; level instead. If a GTIN&lt;br&gt;
appears in an openFDA row and a Safety Gate notification, the lookup returns both, and it is&lt;br&gt;
obvious from the payload that they are two agencies describing one hazard rather than two&lt;br&gt;
hazards. That decision is unglamorous and it is the single most consequential one in the project.&lt;/p&gt;
&lt;h2&gt;
  
  
  Identifiers live in prose, not in fields
&lt;/h2&gt;

&lt;p&gt;The thing you actually want to search by — a barcode, a VIN, a model number — is frequently not a&lt;br&gt;
field.&lt;/p&gt;

&lt;p&gt;Here is a real openFDA &lt;code&gt;code_info&lt;/code&gt; value, lightly trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lot #1234567, Exp 03/2027; UPC 0 36000 29145 2; also lots 1234568,
1234569 (UPC 036000291452) distributed to CA, NV, AZ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That contains one GTIN written two ways and three lot codes, inside a free-text field with no&lt;br&gt;
consistent delimiter. The FDA is not being careless. This is a field designed for a human&lt;br&gt;
reading a recall notice, and it does that job well.&lt;/p&gt;

&lt;p&gt;So identifiers have to be mined out of prose, which means a regex pass, and a regex pass over&lt;br&gt;
free text means false positives. &lt;code&gt;0 36000 29145 2&lt;/code&gt; is a GTIN. So is a 12-digit order reference&lt;br&gt;
that happens to sit next to the word "UPC." The distinguishing feature is the check digit: a&lt;br&gt;
real GTIN's last digit is a mod-10 checksum over the others, and a random 12-digit number passes&lt;br&gt;
that test one time in ten.&lt;/p&gt;

&lt;p&gt;Validating the check digit takes about fifteen lines and throws out roughly a fifth of the&lt;br&gt;
candidates a naive regex finds. Skip it and your API confidently reports recalls against&lt;br&gt;
identifiers that were never barcodes.&lt;/p&gt;

&lt;p&gt;The corollary matters more: &lt;strong&gt;an invalid identifier must be rejected, never answered.&lt;/strong&gt; If&lt;br&gt;
someone queries a malformed GTIN and you return "no recalls found," you have told them their&lt;br&gt;
product is clear when in fact you did not look it up. For a compliance tool that is the worst&lt;br&gt;
possible failure: it is wrong in the direction the customer will not check. We return a 400.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pagination caps nobody documents prominently
&lt;/h2&gt;

&lt;p&gt;openFDA's enforcement endpoints paginate with &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;skip&lt;/code&gt;. The limit maxes at 1,000, which&lt;br&gt;
is generous.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;skip&lt;/code&gt; refuses anything above 25,000.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skip=25000  -&amp;gt;  HTTP 200
skip=25001  -&amp;gt;  HTTP 400  "Skip value must 25000 or less."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The missing word is theirs. I have grown attached to it.&lt;/p&gt;

&lt;p&gt;The device enforcement dataset holds 39,538 records. Sort by report date, page through, and you&lt;br&gt;
hit a wall 14,538 records short of the end, with a &lt;code&gt;200 OK&lt;/code&gt; on the last successful page and&lt;br&gt;
nothing anywhere telling you the history is truncated. Your corpus is quietly incomplete and&lt;br&gt;
every downstream number is confidently wrong.&lt;/p&gt;

&lt;p&gt;I assumed an API key would raise the cap. It does not. A key raises the daily &lt;em&gt;request&lt;/em&gt; quota&lt;br&gt;
from 1,000 to 120,000 and leaves &lt;code&gt;skip&lt;/code&gt; exactly where it was. Registering one would not have&lt;br&gt;
recovered a single record.&lt;/p&gt;

&lt;p&gt;The route past it is date windows. Constrain the search to one year and no window comes close to&lt;br&gt;
the cap. The densest year measured is 3,757 records, so &lt;code&gt;skip&lt;/code&gt; never exceeds 4,000 and the&lt;br&gt;
ceiling stops binding. Sum the per-year totals and you get exactly 39,538. Full coverage, no key.&lt;/p&gt;

&lt;p&gt;RappelConso has the same shape of limit with a different number, and fails more honestly: it&lt;br&gt;
returns an error rather than a truncated success.&lt;/p&gt;
&lt;h2&gt;
  
  
  The 252 MiB surprise
&lt;/h2&gt;

&lt;p&gt;This one cost a production outage, so it is worth being specific.&lt;/p&gt;

&lt;p&gt;The pipeline archives every raw payload before parsing it, so a parser bug never means re-hitting&lt;br&gt;
seven government endpoints to recover. Good rule. It assumes the payload fits in memory.&lt;/p&gt;

&lt;p&gt;Profiling the cold path per source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    wall        cpu     payload
openfda-device    46,889ms   20,672ms   251.9 MiB
openfda-food      16,027ms    4,406ms    27.4 MiB
openfda-drug      13,010ms    2,891ms    21.6 MiB
cpsc              16,729ms    2,734ms    26.1 MiB
nhtsa              5,206ms    4,515ms    14.1 MiB
rappelconso       29,466ms    2,078ms    22.2 MiB
eu-safety-gate    36,083ms    1,313ms     6.4 MiB
                            ─────────
                             38,609ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Device enforcement records carry long product descriptions listing every affected catalogue&lt;br&gt;
number, so 25,000 records come to 252 MiB. This ran on Cloudflare Workers, where an isolate gets&lt;br&gt;
128 MB and 30 seconds of CPU by default. The payload cannot exist. Gzipping it alone — a step&lt;br&gt;
that produces no data — costs 13.6 seconds, 35% of the entire budget.&lt;/p&gt;

&lt;p&gt;The failure mode is what makes it worth writing down. &lt;strong&gt;A Worker killed for exceeding its limits&lt;br&gt;
does not unwind.&lt;/strong&gt; No exception, no &lt;code&gt;catch&lt;/code&gt;, no &lt;code&gt;finally&lt;/code&gt;. The row we had written to say "this&lt;br&gt;
source is running" stayed saying that forever, the failure counter stayed at zero, and the alert&lt;br&gt;
that fires after three consecutive failures never fired, because as far as the database was&lt;br&gt;
concerned nothing had failed.&lt;/p&gt;

&lt;p&gt;Six of seven sources stopped ingesting and every dashboard reported healthy. It was caught by&lt;br&gt;
building a status page and noticing it said "all sources current" beside a tile reading "1 / 7."&lt;/p&gt;

&lt;p&gt;Two lessons, both cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reap your own abandoned work.&lt;/strong&gt; When a run starts, close out anything still marked running from&lt;br&gt;
last time and count it as a failure. That converts an invisible kill into a countable event, and&lt;br&gt;
it is about eight lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not let independent jobs share one budget.&lt;/strong&gt; Seven sources ran in one invocation, so the&lt;br&gt;
most expensive decided whether the cheapest ran at all. One trigger per source costs nothing and&lt;br&gt;
removes the entire failure class.&lt;/p&gt;
&lt;h2&gt;
  
  
  Streaming is not a memory fix
&lt;/h2&gt;

&lt;p&gt;Look again at the profiling table. NHTSA sits near the bottom at 14.1 MiB, one of the cheapest&lt;br&gt;
sources there. That number is a lie, and it is a lie of my own making: it measures the payload&lt;br&gt;
as served, and NHTSA serves a ZIP.&lt;/p&gt;

&lt;p&gt;A profiler that records what arrives on the wire reports this source as harmless. The number that&lt;br&gt;
matters is not visible until something tries to decompress it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; FLAT_RCL_POST_2010.zip
   14713512  FLAT_RCL_POST_2010.zip     &lt;span class="c"&gt;# 14 MB. Cheapest source in the table.&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;unzip &lt;span class="nt"&gt;-l&lt;/span&gt; FLAT_RCL_POST_2010.zip
  308496873  FLAT_RCL_POST_2010.txt     &lt;span class="c"&gt;# 294 MiB. Hm.&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="c"&gt;# isolate memory limit: 128 MB&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="c"&gt;# ah.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The isolate found out before I did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RangeError: Invalid typed array length: 308496873
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not an approximation of the problem, it &lt;em&gt;is&lt;/em&gt; the uncompressed size. The error message and&lt;br&gt;
the file listing agree to the byte, which was the most helpful thing that happened that morning.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unzipSync&lt;/code&gt; allocates the decompressed member as a single buffer, in a 128 MB isolate.&lt;br&gt;
Deterministic, every run, at the same instruction. It never looks intermittent once you have the&lt;br&gt;
number in front of you.&lt;/p&gt;

&lt;p&gt;The obvious fix is to stream: inflate through &lt;code&gt;DecompressionStream&lt;/code&gt;, and collapse rows as they&lt;br&gt;
arrive rather than materialising the file. That reduction is real: the flat file carries one&lt;br&gt;
row per campaign &lt;em&gt;per model&lt;/em&gt;, so 243,199 rows collapse to 15,133 campaigns, and a campaign&lt;br&gt;
covering eight models arrives as eight rows carrying identical defect prose with only the model&lt;br&gt;
name differing. Fold rows in as they arrive and each one becomes garbage immediately.&lt;/p&gt;

&lt;p&gt;I wrote that, measured the retained text at 15.4 MiB, and it still ran out of memory at a&lt;br&gt;
192 MB heap.&lt;/p&gt;

&lt;p&gt;The reason is worth knowing, because it is invisible in the code. In V8,&lt;br&gt;
&lt;code&gt;String.prototype.split&lt;/code&gt; and &lt;code&gt;slice&lt;/code&gt; return &lt;strong&gt;sliced strings&lt;/strong&gt;: a length, an offset, and a&lt;br&gt;
pointer to the string they were cut from. The substring does not own its characters. So&lt;br&gt;
retaining one short field per campaign kept alive every 1 MB decoded chunk that any field had&lt;br&gt;
been cut from, and all 294 MiB stayed reachable. Streaming had moved the allocation without&lt;br&gt;
removing it. The heap profile showed 15 MiB of strings I wanted and 200 MiB of parents I did&lt;br&gt;
not know I was holding.&lt;/p&gt;

&lt;p&gt;The fix is to decode each field from the line's bytes with &lt;code&gt;TextDecoder&lt;/code&gt;, which returns a fresh&lt;br&gt;
string that owns its own characters and points at nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unzipSync + split          killed at 192 MB heap
stream + split             killed at 192 MB heap
stream + decode per field  67.1 MiB peak, 1.6s parse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I would take to the next project. &lt;strong&gt;"Stream it" is not a memory fix by itself.&lt;/strong&gt;&lt;br&gt;
Retention is decided by what the surviving objects point at, and in V8 a substring points at its&lt;br&gt;
parent. And &lt;strong&gt;profile the size you process, not the size you fetch.&lt;/strong&gt; Every compressed source is&lt;br&gt;
a payload figure hiding a multiplier, and this one was 21×.&lt;/p&gt;

&lt;h2&gt;
  
  
  The amendments nobody announces
&lt;/h2&gt;

&lt;p&gt;The part I did not expect to matter most.&lt;/p&gt;

&lt;p&gt;Agencies revise recalls after publication. A recall issued in March covering three lot codes&lt;br&gt;
covers eleven by June. The URL does not change. There is no changelog, no &lt;code&gt;revised_at&lt;/code&gt; field, no&lt;br&gt;
notification. The page just says something different than it did.&lt;/p&gt;

&lt;p&gt;If you check a product against a recall in April, clear it, and the recall widens in June to&lt;br&gt;
include it, nothing tells you. You checked, you have a record of checking, and your record is now&lt;br&gt;
wrong.&lt;/p&gt;

&lt;p&gt;The only way to see this is to diff. Store a content hash per record, compare on every ingest,&lt;br&gt;
and write a field-level delta when it moves. Across this corpus that produces a steady stream of&lt;br&gt;
amendments: models added, date ranges extended, hazard descriptions sharpened. None of it was&lt;br&gt;
announced anywhere.&lt;/p&gt;

&lt;p&gt;For anyone doing this for compliance rather than curiosity, that stream is the actual product.&lt;br&gt;
Under the EU's General Product Safety Regulation the obligation is continuous diligence, and&lt;br&gt;
"we subscribed to a feed" is not evidence of it. What you need is a dated record of what you&lt;br&gt;
checked, against a corpus of known freshness, with the result frozen as it stood, so that a&lt;br&gt;
later amendment does not silently rewrite your own audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are building this yourself
&lt;/h2&gt;

&lt;p&gt;Worth doing. It is a genuinely interesting normalisation problem and the data is free. Budget for&lt;br&gt;
these, which is where the time actually goes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reconciling record granularity across sources — days, and the decision is irreversible&lt;/li&gt;
&lt;li&gt;Mining and check-digit validating identifiers out of prose — a week, and skimping shows&lt;/li&gt;
&lt;li&gt;Finding every undocumented pagination cap — you find these by auditing totals, not by reading docs&lt;/li&gt;
&lt;li&gt;Amendment detection — cheap to build, and worth more than the rest combined&lt;/li&gt;
&lt;li&gt;Not letting one oversized source take down the run — one afternoon, after it bites you once&lt;/li&gt;
&lt;li&gt;Measuring decompressed sizes, not download sizes — five minutes, and it would have saved me two&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schemas are the part that looks hard and isn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The corpus described here is available as an API and as a dated, checksummed export at&lt;br&gt;
&lt;a href="https://recallproven.com" rel="noopener noreferrer"&gt;recallproven.com&lt;/a&gt;. Every source is under an open licence permitting&lt;br&gt;
commercial reuse; the attribution obligations are published at&lt;br&gt;
&lt;a href="https://recallproven.com/v1/sources" rel="noopener noreferrer"&gt;/v1/sources&lt;/a&gt; and travel with every export.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
