The seed packet that had no barcode
My garden app has a seed-packet scanner. Point the camera at a packet, it fills in the variety name and brand, done. It works by decoding the UPC: the small seed houses run Shopify storefronts, their public /products.json lists every product's SKU, and the UPC is just <GS1 prefix><zero-padded SKU><check digit>. One dict entry per vendor.
Then a packet showed up that returned nothing. Fedco is a co-op, not a Shopify shop, so there was no products.json to mirror. Fine — I'd write a scraper.
Except the actual problem was worse than that. I pulled the photo of the back of the packet through zbarimg to see what the scanner was even getting:
$ convert packet.jpg -rotate -90 -colorspace gray -resize 150% w.png
$ zbarimg -q w.png
CODE-39:48011072
Code 39. Not a UPC. Not an EAN. There is no UPC anywhere on the packet — the two barcodes on the back are labelled Item# and Lot#, and they're for the co-op's own warehouse, not for retail. No UPC database on earth indexes them, because they aren't retail barcodes at all.
The front of the packet says item 1072A, lot 4801. The symbol decodes to 48011072. Lot, then item, concatenated.
The catalog is addressable by item number
Poking at their site, product URLs look like /seeds/moon-and-stars-organic-watermelon-1072. The trailing number is the item number. And it turns out the slug doesn't matter at all:
$ curl -sI https://example-seed-co.test/seeds/x-1072 | head -1
HTTP/2 302
location: /seeds/moon-and-stars-organic-watermelon-1072
Any slug works, the site 302s to the canonical page. So the whole lookup is a redirect probe. No catalog mirror, no HTML parsing beyond the <title>, no pagination.
Better: an item number they don't sell just 404s. Which solves the problem I hadn't figured out yet — given 48011072, which half is the item and which is the lot?
def _fedco_item_candidates(code):
if re.fullmatch(r"\d{4}", code):
return [code]
if re.fullmatch(r"\d{8}", code):
return [code[4:], code[:4]] # item second, as observed — try both
return []
Try both halves; only one resolves. 1072 is a watermelon, 4801 is a 404 in every department. The site's own 404 is the disambiguator, and I never have to trust that the byte order I saw on one packet is universal.
That paragraph is wrong, and the rest of this post is about how I found out. I'm leaving it up there because the reasoning looked fine to me at the time and I'd like to be able to see why.
Two details that matter more than they look:
Use HEAD. Their 404 page is a 200KB "no results found" document. A GET per department across six departments is 1.2MB of wasted bandwidth for a miss. HEAD gives you the 302-vs-404 with a zero-byte body.
Only trust a redirect that lands on your item. A site-wide "we've moved" bounce would otherwise read as a hit for every number you probe:
if loc.startswith(BASE + "/") and loc.rstrip("/").endswith("-" + item):
return loc
The collision I nearly shipped
Then I tested a code I knew wasn't theirs and got a confident answer back:
{"found": true, "source": "vendor", "name": "Cross Country Pickling Cucumber",
"lot": "5678"}
An EAN-8 is eight digits. A lot+item pair is eight digits. My splitter happily chopped a real retail barcode in half and matched whichever item shared its last four digits.
The fix is that EAN-8 has a check digit and a concatenated pair of warehouse numbers almost never satisfies it:
def _ean8_valid(code: str) -> bool:
if not re.fullmatch(r"\d{8}", code):
return False
body = [int(d) for d in code[:7]]
total = sum(d * (3 if i % 2 == 0 else 1) for i, d in enumerate(body))
return (10 - total % 10) % 10 == int(code[7])
If the digit agrees, it's a real retail barcode and it earns its UPC lookup first; only on a miss do we start splitting it. 48011072 fails the checksum — check digit should be 5, it's 2 — so it's split with confidence.
The half of the bug that wasn't on the server
Server side done, I went to check the clients and found the actual reason this packet had never scanned:
.setBarcodeFormats(
Barcode.FORMAT_QR_CODE,
Barcode.FORMAT_EAN_13,
Barcode.FORMAT_EAN_8,
Barcode.FORMAT_UPC_A,
Barcode.FORMAT_UPC_E,
Barcode.FORMAT_CODE_128,
Barcode.FORMAT_DATA_MATRIX,
)
No FORMAT_CODE_39. On Android the scanner would never have fired on that packet at all — not "returned no match", just silently nothing, forever, no matter how good the server got. iOS already listed .code39 in its metadata object types, and the web client uses a decoder that supports every format unless you restrict it, so this was one platform quietly missing.
Worth remembering on its own. I spent most of the time on the resolver, and a third of the users couldn't have reached it because of one missing enum in a format list. When you add a symbology, grep every client for the place that enumerates them — the scanner that never fires looks exactly like a lookup that found nothing.
Then it said catnip
Next packet through: item 1021A, Early Moonbeam Watermelon OG, lot 4801. The app filled in Catnip, lot 0002.
The code the camera read was 00024509 — the packet's other Code 39 symbol, the one labelled Item#. And that number is not a lot and an item glued together. It's some internal number whose halves mean nothing: neither 1021 nor 4801 appears anywhere in it. My splitter took it at face value, guessed 4509 was the item, and 4509 is genuinely their catnip. So the wrong answer resolved, passed my "only one half is real" check, and came back looking exactly as confident as a right one.
The bug isn't the split. The bug is that I called a 404 "the disambiguator" without ever asking how often the other outcome happens. Fedco sells something under nearly every 4-digit number. A test where almost every input passes isn't a test. I had one packet, its split happened to work, and I generalized from a sample of one.
What it does now:
hits = [(item, hit) for item in cands
if (hit := _fedco_resolve_item(item)) is not None]
if len(cands) == 2 and len(hits) != 1:
return None # both halves real -> nothing says which is which
Plus a cheap structural check I should have led with: real lots and item numbers both run 1000–9999, and 0002 has a leading zero. A <lot><item> pair can't. That single rule rejects 00024509 before it ever reaches the network.
And when it declines, it doesn't go quiet — it answers brand-only. "Fedco Seeds", variety blank. That costs one typed line, which the learned-barcode cache remembers forever. A wrong variety costs trust in every scan after it, and you don't get told about that one.
The other thing the redirect probe did
While chasing the catnip I noticed the site had stopped answering. Not 403 — nothing. Port 443 wouldn't complete a handshake from any host in my house.
They're behind a WAF, and a walk across six departments per candidate number meant a single 8-digit scan could fire twelve requests at them. That is an enumeration attack. It is only not an attack because of what I happened to intend, which is not a distinction a WAF can make, and it black-holed my address at the TCP layer.
The code made it worse than it had to be:
except Exception:
continue # 404 and "refused at the edge" are now the same thing
Being blocked looked identical to "they don't sell this", so every refusal wrote a six-hour negative cache entry. Item 1021 is a watermelon. My own app had it recorded as nonexistent.
Three changes, none clever:
- One department, not six. It's a seed scanner; probe
/seeds/. hit/miss/erroras three distinct outcomes. Only a miss is remembered. A refusal is never cached as knowledge.- A refused connection, a 403 or a 5xx trips a 24-hour circuit breaker, so an app sitting in somebody's garden can't keep hammering a service that has already said no.
The real fix is the one I skipped at the start: mirror the catalog once, resolve locally, probe nothing. That's what I do for the vendors with a products.json, and I only reached for live probing because this one didn't have an obvious feed. "No obvious feed" was never a good enough reason to put a request to someone else's server in the hot path of a barcode scan.