Baobaobaolin.com
date
entry
034
topic
integration
rev
—

EUR-Lex returns 202 and zero bytes, and curl calls it success: fetching EU law from Cellar instead

A script fetching EU law got a 202 and an empty file, which looks like success. It was a WAF challenge. The Publications Office's Cellar serves the same text, if you ask for a format it actually has.

To read EU law, everyone goes to EUR-Lex. The URL carries a CELEX number (the EU's document identifier; 32009R1223 is the Cosmetics Regulation, (EC) No 1223/2009), so the obvious way to fetch it from a script is to curl that URL.

When I did that on October 13, 2026, I got 202 Accepted and a 0-byte file, and curl -f exited 0. This note is about where that 202 comes from, why common error checks let it through, and how to ask the Publications Office's Cellar for the full text so that you actually get something back. In order: what the 202 is, which checks miss it, how Cellar works, and how to ask it which formats it has.

The 202 is a WAF challenge

The response headers (excerpt):

$ curl -s -o eurlex.html -D - -A 'Mozilla/5.0' \
    'https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32009R1223'
HTTP/1.1 202 Accepted
Server: CloudFront
Content-Length: 0
x-amzn-waf-action: challenge
Cache-Control: no-store, max-age=0
X-Cache: Error from cloudfront

x-amzn-waf-action: challenge is an AWS WAF header. The AWS WAF developer guide ("CAPTCHA and Challenge action behavior") spells it out: for a request without a valid token, the Challenge action blocks it and responds with that header and status 202 Request Accepted; only if the request's Accept header contains text/html does the response include a JavaScript interstitial that runs the challenge script.

I tried both sides of that condition. Without setting Accept myself (curl sends */* by default) the body was 0 bytes; with Accept: text/html,application/xhtml+xml I got 2,035 bytes of HTML containing challenge.js and no legal text. Both were 202. EUR-Lex's own data-reuse help page and its robots.txt returned the same 202 to curl. It's a gate meant for a browser that runs the script, and I didn't try to get around it.

Which checks let it through

202 is in the 2xx range, so any check of the form "below 400 means success" passes it. Three requests in a row, with -f (make curl fail when the server returns an error):

$ for i in 1 2 3; do
    curl -sf -o out$i.html -w '%{http_code} %{size_download} ' \
      'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32026R0909'
    echo "exit=$? bytes=$(wc -c < out$i.html)"
  done
202 0 exit=0 bytes=       0
202 0 exit=0 bytes=       0
202 0 exit=0 bytes=       0

Python's standard urllib.request.urlopen behaves the same way: status 202, 0 bytes read, no exception. If the script writes that straight to a file and hands it to the next step, the failure may only surface when the parser finds no articles, looking like a parser problem rather than the 202. It's the same family as failures that look plausible: a failure that looks like success.

So a script that fetches public data now checks at least four things (with $url and $out set first):

code=$(curl -s -o "$out" -D "$out.h" -w '%{http_code}' "$url") || { echo "curl failed: $url" >&2; exit 1; }
[ "$code" = 200 ] || { echo "HTTP $code: $url" >&2; exit 1; }
grep -qi '^x-amzn-waf-action' "$out.h" && { echo "WAF challenge: $url" >&2; exit 1; }
[ -s "$out" ] || { echo "empty body: $url" >&2; exit 1; }

curl itself has to succeed (transfer errors it can detect make it fail, though content completeness still needs checking separately); the status has to equal 200, not merely be below 400; a WAF header stops the run; the body can't be empty. What you really want to confirm is the content, say a heading the full text must contain, and that belongs in the parsing step that follows.

Cellar: another door to the same document

EUR-Lex documents live in the Publications Office's Cellar. Its interface specification (the "Cellar Interface Specification" published by the Publications Office as tender annex 17, R.1.0.6) defines REST URLs of the form http://publications.europa.eu/resource/celex/{CELEX}: you pick the format with HTTP Accept and the language with Accept-Language, and the server resolves the matching file and redirects to it.

I sent one request per document and Accept value (Accept-Language: eng, following redirects):

CELEXAcceptResult
32009R1223 (original act)application/xhtml+xml200, 2,154,972 bytes
02009R1223-20260518 (consolidated)application/xhtml+xml200, 3,890,782 bytes
32026R0909 (amending act 2026/909)application/xhtml+xml200, 242,629 bytes
all threeapplication/xml;notice=branch200 (metadata)
all threetext/html404, 214 bytes
all threeapplication/pdf404, 214 bytes

The 404 body says exactly what happened:

None of the requests returned successfully a redirection. The following exception
was thrown: [cellar identifier cellar:b8bc7010-429a-11f1-8095-01aa75ed71a1 does not
hold a content datastream of the requested type]

The CELEX number resolved and the document exists; there just isn't a text/html version of it. The first Accept example in the specification is text/html, and its PDF example prefers application/pdf;type=pdf1x (falling back to pdfa1a and pdfa1b at lower weights); requested on their own, text/html, pdf1x and pdfa1a all returned 404 for this 2026 act. The good news is that when you ask for a format that doesn't exist, Cellar answers 404, so curl -f fails with exit code 22 instead of slipping through the way the 202 did.

The consolidated version is the one I wanted: its title is Consolidated TEXT: 32009R1223 — EN — 18.05.2026, it comes to about 530,000 characters once tags are stripped, and the Annex II list of prohibited substances is in it.

Ask it which formats it has

Asking beats guessing. Accept: application/xml;notice=branch returns the document's metadata: the version in the requested language and the formats it holds (the specification calls them manifestations); this response also embedded the other language versions. For 2026/909 the branch notice lists 24 language versions (English appears a second time, expanded in full at the top level, so there are 25 expression entries and 75 manifestation records), each with the same three formats:

pdfa2a  → …01aa75ed71a1.0006.01
fmx4    → …01aa75ed71a1.0006.02
xhtml   → …01aa75ed71a1.0006.03

No html, no pdf1x. Asking from that list, Accept: application/pdf;type=pdfa2a returns a 303 to .0006.01/DOC_1, while type=pdfa1a and type=pdf1x both return 404. fmx4 is the Publications Office's Formex XML.

Two details you'll hit in a script:

  • The redirect Location is http://. The 303 points at http://publications.europa.eu/resource/cellar/…/DOC_1, and curl -L follows it down to http. Requesting the same path over https returned 200 with the same byte count. If transport encryption matters to you, rewrite the redirect to https yourself before fetching.
  • Language codes. The R.1.0.6 specification I read says Accept-Language uses two-letter ISO 639 codes, while the Publications Office's newer "CELLAR End user manual" (8.10.1, December 2023) uses three-letter ISO 639-3 codes; for 2026/909, eng and en both redirected to the same file, and the script below follows the manual with eng. With fra, the consolidated text redirected to a different language version (.0010.02/DOC_2). The specification also describes language fallback: if the requested language lacks that format, it works down the weighted list and returns 404 only when none has it.

How I fetch EU law now: confirm with notice=branch that the English version has xhtml, require a 303 with a Location from the negotiation step, and require a 200, a non-empty file and the expected title from the download, stopping at any step that doesn't match (${loc/http:/https:} is bash syntax).

celex=02009R1223-20260518
base="https://publications.europa.eu/resource/celex/$celex"
code=$(curl -sSL -o "$celex.branch.xml" -w '%{http_code}' \
  -H 'Accept: application/xml;notice=branch' -H 'Accept-Language: eng' "$base") || exit 1
[ "$code" = 200 ] || { echo "branch: HTTP $code" >&2; exit 1; }
python3 - "$celex.branch.xml" <<'PY' || exit 1
import sys, xml.etree.ElementTree as ET
r = ET.parse(sys.argv[1]).getroot()
lang = r.findtext("./EXPRESSION/EXPRESSION_USES_LANGUAGE/URI/IDENTIFIER")
types = [m.findtext("MANIFESTATION_TYPE/VALUE") for m in r.findall("./MANIFESTATION")]
if lang != "ENG" or "xhtml" not in types:
    sys.exit(f"no English xhtml: lang={lang} types={types}")
PY
out=$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' \
  -H 'Accept: application/xhtml+xml' -H 'Accept-Language: eng' "$base") || exit 1
code=${out%% *}; loc=${out#* }
[ "$code" = 303 ] && [ -n "$loc" ] || { echo "negotiation: HTTP $code" >&2; exit 1; }
code=$(curl -sS -o "$celex.en.xhtml" -w '%{http_code}' "${loc/http:/https:}") || exit 1
[ "$code" = 200 ] && [ -s "$celex.en.xhtml" ] || { echo "download: HTTP $code" >&2; exit 1; }
grep -q 'Consolidated TEXT: 32009R1223' "$celex.en.xhtml" || { echo "unexpected content" >&2; exit 1; }

If you remember one thing

Neither a 202 nor exit code 0 means the download worked. This EUR-Lex 202, carrying x-amzn-waf-action: challenge, is what the WAF sends after blocking the request, with either an empty body or a page of challenge script and no legal text, and a "below 400" check will write it into your folder. If what you want is the legal text, go through Cellar: read the branch notice to see which formats exist, then ask for application/xhtml+xml. Asked that way, the original act from the opening comes back as 2,154,972 bytes; switching to the CELEX for the consolidated version of May 18, 2026 gets the 3,890,782-byte text with Annex II in it.

Revision history

  1. First published