Ошибки не проглатываются: S3ObjectNotFoundError (NoSuchKey) и S3ReadError для чтения, S3WriteError для записи, исходная причина — в __cause__. JSON пишется в UTF-8 без экранирования, Content-Type application/json. Шаблонные put_object/get_object удалены (не использовались); удалён и шаблонный nats_callback.py — его единственный импорт S3Storage, сам он нигде не используется (слой NATS — следующим коммитом в src/core/nats.py). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
106 lines
4.9 KiB
Python
106 lines
4.9 KiB
Python
"""S3-клиент сервиса (MinIO через `miniopy-async`): чтение исходника, запись сырого ответа ИС и файла результата.
|
||
|
||
Ошибки не проглатываются и не превращаются в `None`: каждая операция бросает исключение из иерархии
|
||
`S3StorageError` с исходной причиной в `__cause__`, чтобы воркер мог классифицировать сбой (design D-7):
|
||
|
||
- `S3ObjectNotFoundError` (подкласс `S3ReadError`) — объекта нет (`NoSuchKey`);
|
||
- `S3ReadError` — любая другая ошибка чтения (нет доступа, нет бакета, сеть) → воркер отвечает `FILE_READ_ERROR`;
|
||
- `S3WriteError` — ошибка записи (`put_bytes` / `put_json`).
|
||
|
||
`asyncio.CancelledError` не оборачивается — отмена задачи проходит как есть.
|
||
"""
|
||
|
||
import json
|
||
from io import BytesIO
|
||
from typing import Any
|
||
|
||
from miniopy_async import Minio
|
||
from miniopy_async.error import S3Error
|
||
|
||
from src.core.config import MinioSettings
|
||
|
||
OCTET_STREAM = "application/octet-stream"
|
||
JSON_CONTENT_TYPE = "application/json"
|
||
NOT_FOUND_CODES = frozenset({"NoSuchKey"})
|
||
|
||
|
||
class S3StorageError(Exception):
|
||
"""Ошибка операции с S3; `bucket` и `key` — объект, на котором она случилась."""
|
||
|
||
def __init__(self, message: str, *, bucket: str, key: str) -> None:
|
||
super().__init__(message)
|
||
self.bucket = bucket
|
||
self.key = key
|
||
|
||
|
||
class S3ReadError(S3StorageError):
|
||
"""Объект не прочитан (нет доступа, нет бакета, сбой сети, ...)."""
|
||
|
||
|
||
class S3ObjectNotFoundError(S3ReadError):
|
||
"""Объекта с таким ключом в бакете нет."""
|
||
|
||
|
||
class S3WriteError(S3StorageError):
|
||
"""Объект не записан."""
|
||
|
||
|
||
class S3Storage:
|
||
"""Операции сервиса над одним бакетом (`OPENBAO.MINIO.MINIO_BUCKET`); чтение может явно указать другой бакет."""
|
||
|
||
def __init__(self, client: Minio, bucket: str) -> None:
|
||
self.client = client
|
||
self.bucket = bucket
|
||
|
||
@classmethod
|
||
def from_settings(cls, settings: MinioSettings) -> "S3Storage":
|
||
"""Клиент MinIO из настроек: `MINIO_SECURE=false` — http (compose), `true` — https (платформа)."""
|
||
client = Minio(
|
||
settings.MINIO_URL,
|
||
access_key=settings.MINIO_ADMIN_LOGIN,
|
||
secret_key=settings.MINIO_ADMIN_PASSWORD,
|
||
secure=settings.MINIO_SECURE,
|
||
)
|
||
return cls(client, settings.MINIO_BUCKET)
|
||
|
||
async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes:
|
||
"""Содержимое объекта целиком. `bucket` — из запроса (`s3.bucket`), по умолчанию — бакет сервиса.
|
||
|
||
Нет объекта → `S3ObjectNotFoundError`; иная ошибка → `S3ReadError`.
|
||
"""
|
||
bucket_name = bucket or self.bucket
|
||
try:
|
||
response = await self.client.get_object(bucket_name, key)
|
||
try:
|
||
data: bytes = await response.read()
|
||
finally:
|
||
response.release()
|
||
except S3Error as exc:
|
||
error_class = S3ObjectNotFoundError if exc.code in NOT_FOUND_CODES else S3ReadError
|
||
raise error_class(
|
||
f"S3: объект {bucket_name}/{key} не прочитан ({exc.code})", bucket=bucket_name, key=key
|
||
) from exc
|
||
except Exception as exc:
|
||
raise S3ReadError(
|
||
f"S3: объект {bucket_name}/{key} не прочитан ({type(exc).__name__})", bucket=bucket_name, key=key
|
||
) from exc
|
||
return data
|
||
|
||
async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None:
|
||
"""Записывает `data` в бакет сервиса; ошибка → `S3WriteError`."""
|
||
try:
|
||
await self.client.put_object(self.bucket, key, BytesIO(data), length=len(data), content_type=content_type)
|
||
except Exception as exc:
|
||
raise S3WriteError(
|
||
f"S3: объект {self.bucket}/{key} не записан ({type(exc).__name__})", bucket=self.bucket, key=key
|
||
) from exc
|
||
|
||
async def put_json(self, key: str, obj: Any) -> None:
|
||
"""Записывает `obj` как JSON в UTF-8 без `\\u`-экранирования, `Content-Type: application/json`."""
|
||
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||
await self.put_bytes(key, data, content_type=JSON_CONTENT_TYPE)
|
||
|
||
async def close(self) -> None:
|
||
"""Закрывает HTTP-сессию клиента (при остановке приложения)."""
|
||
await self.client.close_session()
|