Give a descriptive error when a DOI cannot be resolved to citation metadata - #1900
Give a descriptive error when a DOI cannot be resolved to citation metadata#1900adityasingh2400 wants to merge 3 commits into
Conversation
…n metadata `dandi service-scripts update-dandiset-from-doi` crashed with a bare `json.JSONDecodeError: Expecting value: line 1 column 1 (char 0)` traceback whenever doi.org answered with anything other than JSON. Two things were wrong. The CSL Accept header was set on the `RESTFullAPIClient` session, but `RESTFullAPIClient.request()` sets `accept: application/json` on the request itself whenever `json_resp` is true, and per-request headers win over session headers. The resolver therefore never saw the citation format we meant to ask for. The checked-in VCR cassettes record this: every captured request carries `accept: application/json`. Crossref happens to serve JSON for that, which is why the existing tests pass, but a resolver that does not will redirect to the landing page and return HTML with a 200. And when that happened there was no error handling at all, so the user got a `JSONDecodeError` out of the requests internals with nothing pointing at the DOI. The fetch now moves into `fetch_doi_citation_metadata()`, which requests the raw response so the intended CSL Accept header survives, and turns a 404, another HTTP error, a non-JSON body, and a non-object body each into a `click.ClickException` naming the DOI, the URL, and the content type received. `normalize_doi()` additionally accepts the DOI as a bare DOI, a `doi:` URI, or a resolver URL, and rejects anything else with a `click.UsageError` instead of a traceback. That also fixes the `relatedResource` record, whose url was built as `https://doi.org/{doi}` and so came out doubled when the user passed a resolver URL. The lookup now happens before connecting to the archive, so a bad DOI fails fast without needing credentials. Closes dandi#1855
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1900 +/- ##
==========================================
+ Coverage 76.96% 77.52% +0.56%
==========================================
Files 88 89 +1
Lines 12882 13457 +575
==========================================
+ Hits 9914 10433 +519
- Misses 2968 3024 +56
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
yarikoptic-gitmate
left a comment
There was a problem hiding this comment.
Reviewed the diff — the core fix is sound (json_resp=False correctly stops the client from overriding the CSL Accept header, except-clause ordering is right, the 18 new tests pass locally, and flake8 is clean). Left 4 inline comments with suggestions:
doidata["author"]/doidata["title"]can still raise rawKeyErrortracebacks for registered DOIs whose CSL record omits those keys — the failure class this PR targets (most severe; anchored at thefetch_doi_citation_metadata()call since the indexing sites are outside the diff).normalize_doi()keeps?query/#fragmentfrom pasted resolver URLs as part of the DOI, which can persist a corrupted identifier into Dandiset metadata.- The 404 message misdiagnoses RA-endpoint 404s ("not registered — check for typos") for DOIs that are registered but whose agency doesn't serve CSL metadata.
DOI_REGEXrejects valid legacy sub-registrant DOIs (10.1000.10/123) that the old code accepted.
Generated by Claude Code
| for prefix_regex in DOI_PREFIX_REGEXES: | ||
| if m := re.match(prefix_regex, value, flags=re.I): | ||
| value = value[m.end() :].strip() | ||
| break |
There was a problem hiding this comment.
Since \S+ matches ? and #, a resolver URL pasted straight from a browser — e.g. https://doi.org/10.1234/foo?locatt=mode:legacy — normalizes to 10.1234/foo?locatt=mode:legacy. On a successful lookup that corrupted identifier is then persisted into the Dandiset's relatedResource metadata (and a #fragment variant silently looks up a different DOI than the one stored, since requests drops the fragment before sending).
Stripping the query string/fragment only in the URL spellings keeps bare DOIs containing ? (technically legal, if rare) working:
| for prefix_regex in DOI_PREFIX_REGEXES: | |
| if m := re.match(prefix_regex, value, flags=re.I): | |
| value = value[m.end() :].strip() | |
| break | |
| for prefix_regex in DOI_PREFIX_REGEXES: | |
| if m := re.match(prefix_regex, value, flags=re.I): | |
| # a resolver URL pasted from a browser may carry a query string | |
| # or fragment that is not part of the DOI itself | |
| value = re.split(r"[?#]", value[m.end() :].strip(), maxsplit=1)[0] | |
| break |
Generated by Claude Code
There was a problem hiding this comment.
Done, with one deliberate deviation. Your prose says to strip only in the URL spellings, but the suggestion loop strips for every entry in DOI_PREFIX_REGEXES, which includes doi:. That would take doi:10.1234/foo?bar down to 10.1234/foo, which is the case your prose sets out to protect.
So I gated it on the URL prefix specifically:
for prefix_regex in DOI_PREFIX_REGEXES:
if m := re.match(prefix_regex, value, flags=re.I):
value = value[m.end() :].strip()
if prefix_regex is DOI_URL_PREFIX_REGEX:
value = re.split(r"[?#]", value, maxsplit=1)[0]
breakCovered by test_normalize_doi_prefix_and_url_suffix, which asserts both directions: https://doi.org/10.1234/foo?locatt=mode:legacy becomes 10.1234/foo, while 10.1234/foo?bar and doi:10.1234/foo#bar keep their suffix.
| start_time = datetime.now().astimezone() | ||
| # Resolve the DOI before talking to the archive, so that a bad DOI fails | ||
| # fast and without requiring credentials | ||
| doidata = fetch_doi_citation_metadata(doi) |
There was a problem hiding this comment.
fetch_doi_citation_metadata() only validates that the record is a dict, but downstream this command still hard-indexes doidata["author"] (line 387) and doidata["title"] (line 442). A DOI that resolves to valid CSL JSON without one of those keys — real for some Crossref record types such as editorials/corrections, or records with only organizational creators — still crashes with a raw KeyError traceback, the exact failure class this PR sets out to eliminate. (name/description are safe: copy_str_from_doi_to_metadata() uses .get().)
Validating up front, only for the fields actually being updated, keeps the descriptive-error guarantee:
| doidata = fetch_doi_citation_metadata(doi) | |
| doidata = fetch_doi_citation_metadata(doi) | |
| for field, key in (("contributor", "author"), ("relatedResource", "title")): | |
| if field in fields and key not in doidata: | |
| raise click.ClickException( | |
| f"Citation metadata for DOI {doi} has no {key!r} field, which " | |
| f"is needed to update {field!r}." | |
| ) |
Generated by Claude Code
There was a problem hiding this comment.
Done. Same logic, lifted into a named helper so it can be tested directly rather than only through the command:
DOI_REQUIRED_KEYS = {"contributor": "author", "relatedResource": "title"}
def check_doi_fields(doi, doidata, fields):
missing = {
key: field
for field, key in DOI_REQUIRED_KEYS.items()
if field in fields and key not in doidata
}
...called right after fetch_doi_citation_metadata(doi). Only the requested --fields are checked, so --fields name,description still works on a record with no author or title, which copy_str_from_doi_to_metadata() handles with .get() anyway.
test_check_doi_fields_missing and test_check_doi_fields_ok cover both directions.
|
@adityasingh2400 thanks for the PR. did you use that service script in battlefield for some dandisets? |
…ssing CSL keys Claude-Session: https://claude.ai/code/session_0189zxefNUxZmXLRED6jPeMy
|
Thanks both. All four review points are addressed in
Five tests cover these. Against the pre-review source 7 of the new cases fail, including the 404 one asserting the old wording, and the 2 that pass are the bare-DOI cases that were already correct. Full file is 35 passed, 6 skipped. @yarikoptic on your two questions, honestly: no to both. I have not run this against a real Dandiset, only against mocked resolver responses and the live DOI resolver for the happy path, and I have not tried the interactive metadata helper. So I cannot claim this is better than what that helper already gets you. If the helper covers this ground, the useful part of this PR is probably just the error handling rather than the feature, and I am happy for it to be scoped down or closed. Still a draft. Say the word and I will mark it ready. |

Fixes #1855
update-dandiset-from-doicrashed with a barejson.JSONDecodeError: Expecting value: line 1 column 1 (char 0)whenever doi.org answered with anything other than JSON.There are two separate problems behind that traceback.
The first is a header bug. The CSL Accept header was set on the
RESTFullAPIClientsession, butRESTFullAPIClient.request()setsaccept: application/jsonon the request wheneverjson_respis true, and in requests a per-request header wins over a session header. The resolver therefore never saw the citation format we meant to ask for. The checked-in VCR cassettes record this, every captured request carriesaccept: application/jsonand never the CSL type. Crossref happens to serve JSON for that anyway, because it redirects toapi.crossref.org/.../transform, which is why the existing tests pass. A registration agency that does not will redirect to the landing page and return HTML with a 200, which is the reported failure on a DataCite10.48324DOI.The second is that there was no error handling at all on that path, so the user got a
JSONDecodeErrorraised from inside requests with nothing naming the DOI.The fetch moves into
fetch_doi_citation_metadata(), which requests the raw response so the intended CSL Accept header survives, and turns a 404, any other HTTP error, a non-JSON body, and a non-object body each into aclick.ClickExceptionnaming the DOI, the URL, and the content type actually received.normalize_doi()now accepts a bare DOI, adoi:URI, or a resolver URL, and rejects anything else with aclick.UsageErrorinstead of a traceback. That also fixes therelatedResourcerecord, whose url was built ashttps://doi.org/{doi}and came out doubled when the user passed a resolver URL. The lookup now happens before connecting to the archive, so a bad DOI fails fast without needing credentials.Only
title,abstract, andauthor[*].given/family/ORCID/affiliationare read, and all of those are present in both CSL JSON and the Crossref record the cassettes captured, so replaying the existing cassettes is unaffected. vcrpy matches on method and URI, not headers.Verified against the base ref with doi.org mocked to serve HTML at 200. Before, the run ends in
requests.exceptions.JSONDecodeErrorfromdandiapi.pyline 326. After, it reports that the DOI answered withtext/htmlinstead of CSL JSON and explains that the registration agency likely does not serve citation metadata. The Accept header actually sent went fromapplication/jsontoapplication/vnd.citationstyles.csl+json; charset=utf-8.New tests are marked
@pytest.mark.ai_generated. They give 18 passed. The 6 deselected are the VCRtest_update_dandiset_from_doicases, which need the docker archive fixture and could not run locally.AI assistance disclosure: this change was written with the help of Claude Code, and the added tests are marked
ai_generatedas CLAUDE.md asks. I reviewed and tested everything before submitting.