Merge pull request #527 from ggozad/fix/s3-error-classification

Classify obstore config errors as permanent
This commit is contained in:
Yiorgis Gozadinos 2026-08-06 12:03:03 +02:00 committed by GitHub
commit a2afdddc63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 64 additions and 1 deletions

View file

@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Fixed
- Ingester jobs failing with an `obstore` `PermissionDeniedError`, `UnauthenticatedError`, `UnknownConfigurationKeyError` or `InvalidPathError` are dead-lettered instead of retried to `max_attempts`.
## [0.72.1] - 2026-07-31
### Changed

View file

@ -351,7 +351,8 @@ job at a time. `worker_count` is therefore also the maximum number of
concurrent in-flight jobs. Jobs that hit a `TransientError` are
rescheduled with exponential backoff plus jitter, up to `max_attempts`,
then land in the dead-letter queue. `PermanentError` (unsupported
extension, 4xx HTTP except 408/429, etc.) skips retry entirely.
extension, 4xx HTTP except 408/429, object-store credential and
configuration errors, etc.) skips retry entirely.
While a worker processes a job it renews the job's lease every
`heartbeat_interval_s`. A reaper task resets any claim whose lease has not

View file

@ -3,6 +3,12 @@ from contextlib import nullcontext
from typing import TYPE_CHECKING
import httpx
from obstore.exceptions import (
InvalidPathError,
PermissionDeniedError,
UnauthenticatedError,
UnknownConfigurationKeyError,
)
from pydantic import BaseModel
from haiku.rag.client.exceptions import UnsupportedSourceError
@ -62,6 +68,19 @@ def _classify(exc: BaseException) -> Exception:
# ProxyError — every transport-layer failure that's worth retrying.
return TransientError(f"network: {exc}")
if isinstance(
exc,
PermissionDeniedError
| UnauthenticatedError
| UnknownConfigurationKeyError
| InvalidPathError,
):
# These subclass neither OSError nor httpx, so without this branch
# they'd fall through to the transient default and retry the whole
# backoff ladder. A missing object arrives as a builtin
# FileNotFoundError instead, handled below.
return PermanentError(f"object store: {exc}")
if isinstance(exc, FileNotFoundError):
return PermanentError(f"file not found: {exc}")

View file

@ -3,6 +3,14 @@ from unittest.mock import AsyncMock
import httpx
import pytest
from obstore.exceptions import (
GenericError,
InvalidPathError,
JoinError,
PermissionDeniedError,
UnauthenticatedError,
UnknownConfigurationKeyError,
)
from haiku.rag.client import HaikuRAG
from haiku.rag.ingester.exceptions import PermanentError, TransientError
@ -541,3 +549,34 @@ async def test_file_too_large_classified_as_permanent():
client.create_document_from_source.side_effect = FileTooLargeError("too big")
with pytest.raises(PermanentError, match="too big"):
await run_job(client, _job())
@pytest.mark.parametrize(
"exc_class",
[
PermissionDeniedError,
UnauthenticatedError,
UnknownConfigurationKeyError,
InvalidPathError,
],
)
@pytest.mark.asyncio
async def test_obstore_config_errors_classified_permanent(exc_class):
client = _mock_client()
client.create_document_from_source.side_effect = exc_class("bad config")
with pytest.raises(PermanentError, match="object store"):
await run_job(client, _job())
@pytest.mark.parametrize(
"exc_class",
[GenericError, JoinError],
)
@pytest.mark.asyncio
async def test_other_obstore_errors_classified_transient(exc_class):
"""Only the credential/configuration errors are permanent; umbrellaing on
obstore's BaseError would sweep up retryable failures too."""
client = _mock_client()
client.create_document_from_source.side_effect = exc_class("upstream hiccup")
with pytest.raises(TransientError):
await run_job(client, _job())