Two related findings from reviewing ord-schema#920, which bounded the resolver requests on that side.
1. /v1/resolve_input runs a blocking call on the event loop
resources/v1/utilities.py:61-68 is async def, but resolvers.resolve_input is imported from ord_schema and is fully synchronous — it performs urllib requests to PubChem, CIR, and OPSIN inline. Calling it directly from a coroutine stalls the entire event loop for the duration, not just that request.
The worst case is bounded but large. resolve_input on the "[AMOUNT] of [SOLUTE] in [SOLVENT]" form builds two compounds, and each falls through up to three resolvers. Once ord-schema#920 lands, that is 2 x 3 x 10 s = 60 s of full event-loop stall; today, with no timeouts in the released ord-schema, it is unbounded.
This looks like an oversight rather than a decision, because the codebase already knows the pattern: run_in_threadpool is used in about ten places across domain/reactions.py, domain/datasets.py, and services/pb_utils.py, and the sibling endpoint resolve-compound immediately below correctly awaits an async resolver. Wrapping the call in run_in_threadpool is the fix.
2. services/resolvers.py is a second resolver stack
services/resolvers.py implements name resolution independently of ord_schema.resolvers, on httpx.AsyncClient. It is not strictly worse — fanning out concurrently with asyncio.as_completed and cancelling the losers is a genuine latency win over ord-schema's sequential chain, and the alru_cache is useful. But it has drifted in three ways worth correcting:
A dead resolver. _emolecules_resolve requests lookup?q=, the endpoint ord-schema documents as permanently unusable: it always replies __END__, and the current eMolecules API requires authentication. One of the three concurrent branches cannot ever succeed, so this is a guaranteed-failing request per lookup.
No response size cap. response.raise_for_status().text buffers whatever arrives. A resolver answers with a single SMILES, so an error page or redirect loop is buffered in full.
Timeouts bound each operation, not the request. httpx defaults to 5 s, so this is not unbounded — but that budget is per-chunk, and it resets whenever bytes arrive. A server trickling data holds the connection indefinitely. This is precisely the failure mode ord-schema#920 addresses, and the fix there needed a wall-clock deadline plus read1, because a per-operation timeout cannot express a request ceiling on its own.
Suggested direction
Consolidating on ord_schema.resolvers via run_in_threadpool fixes both findings at once and leaves one resolver implementation to maintain. The tradeoff to weigh is that ord-schema resolves sequentially, so a slow PubChem delays the fallback rather than racing it — if the concurrent fan-out is worth keeping, the alternative is keeping this module but adding a size cap, a request-wide deadline, and dropping the eMolecules branch.
Either way, finding 1 is worth fixing on its own and is a two-line change.
🤖 Generated with Claude Code
Two related findings from reviewing ord-schema#920, which bounded the resolver requests on that side.
1.
/v1/resolve_inputruns a blocking call on the event loopresources/v1/utilities.py:61-68isasync def, butresolvers.resolve_inputis imported fromord_schemaand is fully synchronous — it performsurllibrequests to PubChem, CIR, and OPSIN inline. Calling it directly from a coroutine stalls the entire event loop for the duration, not just that request.The worst case is bounded but large.
resolve_inputon the"[AMOUNT] of [SOLUTE] in [SOLVENT]"form builds two compounds, and each falls through up to three resolvers. Once ord-schema#920 lands, that is 2 x 3 x 10 s = 60 s of full event-loop stall; today, with no timeouts in the released ord-schema, it is unbounded.This looks like an oversight rather than a decision, because the codebase already knows the pattern:
run_in_threadpoolis used in about ten places acrossdomain/reactions.py,domain/datasets.py, andservices/pb_utils.py, and the sibling endpointresolve-compoundimmediately below correctly awaits an async resolver. Wrapping the call inrun_in_threadpoolis the fix.2.
services/resolvers.pyis a second resolver stackservices/resolvers.pyimplements name resolution independently oford_schema.resolvers, onhttpx.AsyncClient. It is not strictly worse — fanning out concurrently withasyncio.as_completedand cancelling the losers is a genuine latency win over ord-schema's sequential chain, and thealru_cacheis useful. But it has drifted in three ways worth correcting:A dead resolver.
_emolecules_resolverequestslookup?q=, the endpoint ord-schema documents as permanently unusable: it always replies__END__, and the current eMolecules API requires authentication. One of the three concurrent branches cannot ever succeed, so this is a guaranteed-failing request per lookup.No response size cap.
response.raise_for_status().textbuffers whatever arrives. A resolver answers with a single SMILES, so an error page or redirect loop is buffered in full.Timeouts bound each operation, not the request. httpx defaults to 5 s, so this is not unbounded — but that budget is per-chunk, and it resets whenever bytes arrive. A server trickling data holds the connection indefinitely. This is precisely the failure mode ord-schema#920 addresses, and the fix there needed a wall-clock deadline plus
read1, because a per-operation timeout cannot express a request ceiling on its own.Suggested direction
Consolidating on
ord_schema.resolversviarun_in_threadpoolfixes both findings at once and leaves one resolver implementation to maintain. The tradeoff to weigh is that ord-schema resolves sequentially, so a slow PubChem delays the fallback rather than racing it — if the concurrent fan-out is worth keeping, the alternative is keeping this module but adding a size cap, a request-wide deadline, and dropping the eMolecules branch.Either way, finding 1 is worth fixing on its own and is a two-line change.
🤖 Generated with Claude Code