Retry transient ontology service failures with exponential backoff - #74
Conversation
… the run A remote ontology service (EBI's OLS above all) stalls or drops the occasional request without being down. Because an unreachable service aborts the run with "unable to validate" (exit 2), one stalled request was enough to fail a CI build whose data is fine - on whichever CURIE happened to be in flight, so the failure said nothing about the diff. Downstream that pushed projects to give up the online check entirely (monarch-initiative/dismech#10396, and its PR #12102 moving schema term validation offline in CI), which is a workaround for a gap that belongs here. Every lookup that raises OntologyServiceUnavailableError - a connect/read timeout, a 5xx, a 408 or a 429 - is now retried under a RetryPolicy: two extra attempts by default, 1s then 2s apart. Only that error is retried; a missing term or a wrong label is a definitive answer and is never re-asked, so no data error is papered over and a real outage still ends the run, just after the attempts are spent. - OntologyAccess funnels get_label, is_obsolete and entity_aliases through retry_service_call(); BaseOntologyPlugin._traverse() does the same for graph traversal, so dynamic-enum expansion is covered too. - Adapter construction is retried and classified as well: building an adapter can download an ontology database, and a stalled download used to surface as a raw traceback rather than "unable to validate". - Settings follow the Not4Curation precedence: --retries / --retry-wait > oak_config.yaml (service_retries, service_retry_backoff) > defaults, with 0 restoring fail-fast. All four term-resolving CLI commands carry the flags. - tests/conftest.py zeroes the default backoff for the suite, so the existing outage tests do not start waiting out real sleeps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2XfAMx1VLZvHbfgCmCjvV
|
Claude finished @cmungall's task in 4m 42s —— View job PR review: retry transient ontology service failures
This is a well-scoped change. It retries only I found one gap that can stop the retry from ever running during graph traversal, plus a few smaller points. 1. A lazy traversal result can escape the classifier, so it is never retried (likely)In try:
results = method([start_curie], **kwargs)
except ...:
raise_if_service_unavailable(start_curie, e)
raise
values = set(results or []) # <- iteration happens here, outside the tryMany OAK
Suggested fix: move 2. HTTP 429 gets the same short backoff as a timeout
3. Retrying adapter construction can repeat a large download
4. Smaller points
Security / performance
Test coverageCoverage is good. It checks policy semantics, config precedence, bad config values, that the cache isn't filled on failure, that a 404 is not retried, the backoff timing, and label, alias, obsolescence, adapter-construction and traversal lookups, plus the CLI end to end. Gaps:
I did not run the test suite: installing dependencies ( |
… review The review's first finding is real: _call_graph_traversal materialized the result *outside* the classifying try, and OAK traversals commonly answer with a generator, so the requests only run while the set is being built. A read timeout raised there stayed a raw requests error - never turned into OntologyServiceUnavailableError, so never retried, a traceback in greedy expansion and a silent "not reachable" in the progressive check. The new generator-based test fails on the previous code and passes now. Also from the review: - Retry-After on a rate-limited response (429) now overrides the backoff when the service asks for longer, clamped so an outlandish value cannot hang the run. retry_after_seconds() reads only the delta-seconds form; the HTTP-date form is ignored rather than guessed at. - A single wait is capped at MAX_SERVICE_RETRY_DELAY (60s), so a generous --retries cannot stall for minutes (--retries 10 was ~17 of them). A backoff explicitly set above the cap is still honored. - The outage message no longer claims a retry that did not happen: the retry loop records the attempts it spent on the exception, and the CLI reports "after N attempt(s)", so --retries 0 reads truthfully. - retry_on_service_unavailable resolves its default policy through resolve_retry_policy(), so the module defaults (and the test fixture that patches them) reach the no-policy path too; the fixture docstring now says exactly what it covers. - parse_retry_config's two duplicate backoff branches merged into one. - is_connectivity_error and retry_after_seconds share one _exception_chain() walk, with the raise-from-None semantics unchanged. - Tests added for the OLS payload retry paths (is_obsolete, entity_aliases). On the review's third point: pystow's download() passes clean_on_failure=True, which unlinks the partial file on any exception, so a timed-out download leaves no truncated .db for the next attempt to "succeed" on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2XfAMx1VLZvHbfgCmCjvV
|
Claude finished @cmungall's task in 49s —— View job Re-review of PR #74 after 276e5aa
The new commit fixes every point from the last review. I found nothing blocking. Two small follow-ups are below. Previous findings
New code
Small follow-ups (optional)
Security / performance
Test coverageThe new tests cover the generator traversal, I didn't run the test suite: installing dependencies ( |
|
Review addressed in 1. Lazy traversal result escaping the classifier — confirmed and fixed. 2. Retry-After — implemented. 3. Retried download leaving a truncated 4. Smaller points — all taken.
Test-coverage gaps: added the OLS payload paths ( Also folded into this push: Local checks before pushing: Generated by Claude Code |
Follow-up from the re-review of #74. _ols_descendants pages OLS directly and _expand_reachable_from called it outside _traverse, so a timeout there was neither classified nor retried - a raw client traceback out of greedy expansion, on exactly the path OLS-backed dynamic enums take when descendants() answers nothing. get_paged is a generator, so the guard has to cover the iteration as well as the call, the same trap the previous commit fixed in _call_graph_traversal. The fallback now runs through ontology.retry_service_call(), and both new tests were checked against the unwired code: the retry test fails there and passes now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2XfAMx1VLZvHbfgCmCjvV
|
Both re-review follow-ups handled in 1. OLS descendant fallback — fixed rather than deferred. You're right that it's the same gap as #1 on the path OLS-backed enums actually take, so leaving it would make this PR's claim only half true. 2. On your note about nested retry loops overwriting Local checks before pushing: Generated by Claude Code |
|
Claude finished @cmungall's task in 28s —— View job Re-review of PR #74 after 971d62e
This commit fixes follow-up #1 from the last review. I found nothing blocking, and I think the PR is ready to merge. What changed
The call site (
Tests
Minor notes (optional)
Security / performance
Test coverageThe paths the PR now covers are all tested: label, obsolete and alias lookups (both OAK and OLS payloads), adapter construction, eager and lazy traversal, the OLS paging fallback, I didn't run the test suite here, because installing dependencies ( |
Summary
Adds automatic retry logic for transient ontology service failures (timeouts, 5xx errors, 429s) so that a single stalled request no longer fails an entire validation run. Implements a configurable
RetryPolicywith exponential backoff that is applied to all network-touching operations inOntologyAccessandDynamicEnumPlugin.Key Changes
New retry infrastructure (
oak_utils.py):RetryPolicydataclass: configurable retry count and exponential backoffretry_on_service_unavailable(): decorator-like function that retries onlyOntologyServiceUnavailableError, not definitive failuresparse_retry_config()andresolve_retry_policy(): configuration parsing with precedence (CLI flag >oak_config.yaml> defaults)DEFAULT_SERVICE_RETRIES = 2andDEFAULT_SERVICE_RETRY_BACKOFF = 1.0OntologyAccess integration:
service_retriesandservice_retry_backoffparametersretry_service_call()method funnels all network operations through retry logicget_label(),is_obsolete(),entity_aliases(), and adapter construction with retriesCLI enhancements (
cli.py):--retriesand--retry-waitflags onvalidate-schemaandvalidate-datacommandsRetriesOptionandRetryWaitOptiontype annotations prevent driftPlugin updates:
PermissibleValuePlugin,DynamicEnumPlugin,BindingPlugin) accept and pass throughservice_retriesandservice_retry_backoffDynamicEnumPlugin._traverse()wraps graph traversal operations with retry logicConfiguration model (
models.py):service_retriesandservice_retry_backofffields toValidationConfigComprehensive test suite (
test_service_retry.py):Implementation Details
OntologyServiceUnavailableErroris retried; definitive answers (404, validation errors, config errors) are raised immediatelyoak_config.yaml> built-in defaults (consistent with Not4Curation behavior)OntologyAccess._sleepis injectable for tests to skip actual waitingFixes monarch-initiative/dismech#10396 where a single stalled ontology service request would fail an entire CI build despite valid data.
https://claude.ai/code/session_01A2XfAMx1VLZvHbfgCmCjvV