Skip to content

Commit bce7796

Browse files
jopemachineclaude
andcommitted
refactor(BA-5978): swap except ValidationError -> BackendAIModelValidationFailed
Across the codebase, audit every ``except ValidationError`` site and: * For specific ``Foo.model_validate(...)`` call sites where ``Foo`` is a ``BackendAIModel`` subclass, swap to ``except BackendAIModelValidationFailed``. The auto-conversion override means raw ``ValidationError`` no longer reaches these sites. * For generalized middleware / decorator entry points (``pydantic_params_api_handler``, ``extract_param_value``, ``convert_validation_error``, ``PydanticJWTValidator.validate``, the appproxy coordinator's ``exception_middleware``), keep BOTH ``BackendAIModelValidationFailed`` AND ``ValidationError`` branches with a comment explaining the ``ValidationError`` arm is a defensive fallback for plain ``BaseModel`` subclasses that bypass the auto-conversion override. * For constructor calls (``Model(**data)``) and ``TypeAdapter.validate_python(...)``, leave ``ValidationError`` untouched — the override is opt-in on the ``model_validate`` classmethods only. No public response shapes change; the swap is purely about which type the right branch catches now that all project Pydantic models inherit ``BackendAIModel``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e43f653 commit bce7796

11 files changed

Lines changed: 74 additions & 25 deletions

File tree

src/ai/backend/account_manager/api/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pydantic import BaseModel, ConfigDict, ValidationError
1414

1515
from ai.backend.account_manager.exceptions import AuthorizationFailed, InvalidAPIParameters
16+
from ai.backend.common.exception import BackendAIModelValidationFailed
1617
from ai.backend.common.types import BackendAIModel
1718
from ai.backend.logging import BraceStyleAdapter
1819

@@ -174,7 +175,12 @@ async def wrapped(
174175
kwargs["query"] = query_params
175176
except (json.decoder.JSONDecodeError, yaml.YAMLError, yaml.MarkedYAMLError) as e:
176177
raise InvalidAPIParameters("Malformed body") from e
178+
except BackendAIModelValidationFailed as e:
179+
raise InvalidAPIParameters("Input validation error", extra_data=e.extra_data) from e
177180
except ValidationError as e:
181+
# Defensive fallback: plain ``BaseModel`` subclasses (no
182+
# BackendAIModel inheritance) skip the auto-conversion
183+
# override and raise raw ``ValidationError``.
178184
raise InvalidAPIParameters("Input validation error", extra_data=e.errors()) from e
179185
result = await handler(request, checked_params, *args, **kwargs)
180186
return ensure_stream_response_type(result)

src/ai/backend/agent/server.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
from callosum.ordering import ExitOrderedAsyncScheduler
4545
from callosum.rpc import Peer, RPCMessage
4646
from etcd_client import WatchEventType
47-
from pydantic import ValidationError
4847
from setproctitle import setproctitle
4948
from zmq.auth.certs import load_certificate
5049

@@ -87,7 +86,7 @@
8786
KernelTerminatedBroadcastEvent,
8887
)
8988
from ai.backend.common.events.event_types.kernel.types import KernelLifecycleEventReason
90-
from ai.backend.common.exception import ConfigurationError
89+
from ai.backend.common.exception import BackendAIModelValidationFailed, ConfigurationError
9190
from ai.backend.common.health_checker.checkers.etcd import EtcdHealthChecker
9291
from ai.backend.common.health_checker.checkers.valkey import ValkeyHealthChecker
9392
from ai.backend.common.health_checker.probe import HealthProbe, HealthProbeOptions
@@ -1740,7 +1739,7 @@ def main(
17401739
if server_config.debug.enabled:
17411740
print("== Agent configuration ==")
17421741
pprint(server_config.model_dump(by_alias=True))
1743-
except ValidationError as e:
1742+
except BackendAIModelValidationFailed as e:
17441743
print(
17451744
"ConfigurationError: Agent local config failed validation checks:",
17461745
file=sys.stderr,

src/ai/backend/appproxy/common/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from ai.backend.appproxy.common.types import PydanticResponse
3030
from ai.backend.common import redis_helper
31+
from ai.backend.common.exception import BackendAIModelValidationFailed
3132
from ai.backend.common.types import RedisConnectionInfo
3233
from ai.backend.logging import BraceStyleAdapter
3334

@@ -232,7 +233,11 @@ async def wrapped(
232233
kwargs["query"] = query_params
233234
except (json.decoder.JSONDecodeError, yaml.YAMLError, yaml.MarkedYAMLError) as e:
234235
raise InvalidAPIParameters("Malformed body") from e
236+
except BackendAIModelValidationFailed as e:
237+
raise InvalidAPIParameters("Input validation error", extra_data=e.extra_data) from e
235238
except ValidationError as e:
239+
# Defensive fallback for plain ``BaseModel`` subclasses that
240+
# bypass the BackendAIModel auto-conversion override.
236241
raise InvalidAPIParameters("Input validation error", extra_data=e.errors()) from e
237242
result = await handler(request, checked_params, *args, **kwargs)
238243
return ensure_stream_response_type(result)

src/ai/backend/appproxy/coordinator/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
from ai.backend.common.defs import REDIS_LIVE_DB, REDIS_STREAM_DB, REDIS_STREAM_LOCK, RedisRole
8181
from ai.backend.common.etcd import ConfigScopes
8282
from ai.backend.common.events.dispatcher import EventDispatcher, EventProducer
83-
from ai.backend.common.exception import BackendAIError
83+
from ai.backend.common.exception import BackendAIError, BackendAIModelValidationFailed
8484
from ai.backend.common.health_checker.checkers.valkey import ValkeyHealthChecker
8585
from ai.backend.common.health_checker.probe import HealthProbe, HealthProbeOptions
8686
from ai.backend.common.health_checker.types import ComponentId
@@ -197,7 +197,13 @@ async def exception_middleware(
197197
root_ctx: RootContext = request.app["_root.context"]
198198
try:
199199
resp = await handler(request)
200+
except BackendAIModelValidationFailed as ex:
201+
log.exception("Failed to create response model: {}", ex.extra_msg)
202+
raise InternalServerError() from ex
200203
except ValidationError as ex:
204+
# Defensive fallback: a stray plain ``BaseModel`` subclass that
205+
# skipped the BackendAIModel auto-conversion would still surface
206+
# its raw ``ValidationError`` here.
201207
log.exception("Failed to create response model: {}", ex.json(indent=2))
202208
raise InternalServerError() from ex
203209
except BackendAIError as ex:

src/ai/backend/appproxy/worker/config.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Annotated, Self
1111

1212
import click
13-
from pydantic import AnyUrl, Field, FilePath, ValidationError, model_validator
13+
from pydantic import AnyUrl, Field, FilePath, model_validator
1414

1515
from ai.backend.appproxy.common.config import (
1616
BaseSchema,
@@ -38,6 +38,7 @@
3838
PyroscopeConfig,
3939
ServiceDiscoveryConfig,
4040
)
41+
from ai.backend.common.exception import BackendAIModelValidationFailed
4142
from ai.backend.common.meta import BackendAIConfigMeta, CompositeType, ConfigExample
4243
from ai.backend.common.typed_validators import AutoDirectoryPath
4344
from ai.backend.common.types import ServiceDiscoveryType
@@ -1002,12 +1003,12 @@ def load(config_path: Path | None = None, log_level: LogLevel = LogLevel.NOTSET)
10021003
raise ConfigValidationError("Pyroscope enabled but config is not populated")
10031004
if server_config.profiling.pyroscope_config.application_name is None:
10041005
server_config.profiling.pyroscope_config.application_name = f"proxy-worker-{server_config.proxy_worker.authority}-{server_config.proxy_worker.api_bind_addr.port}"
1005-
except (ValidationError, ConfigValidationError) as e:
1006+
except (BackendAIModelValidationFailed, ConfigValidationError) as e:
10061007
print(
10071008
"ConfigurationError: Could not read or validate the manager local config:",
10081009
file=sys.stderr,
10091010
)
1010-
if isinstance(e, ValidationError):
1011+
if isinstance(e, BackendAIModelValidationFailed):
10111012
detail = str(e)
10121013
else:
10131014
detail = pformat(e)

src/ai/backend/client/cli/v2/deployment/chat/types.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from datetime import datetime, timedelta
88
from typing import Annotated, Self
99

10-
from pydantic import Field, ValidationError
10+
from pydantic import Field
1111

1212
from ai.backend.client.cli.v2.deployment.chat.utils import (
1313
CHAT_CACHE_FILE,
@@ -16,6 +16,7 @@
1616
read_json_file,
1717
write_json_file,
1818
)
19+
from ai.backend.common.exception import BackendAIModelValidationFailed
1920
from ai.backend.common.identifier.deployment import DeploymentID
2021
from ai.backend.common.types import BackendAIModel
2122

@@ -72,7 +73,7 @@ def load(cls) -> Self:
7273
return cls()
7374
try:
7475
return cls.model_validate(raw)
75-
except ValidationError:
76+
except BackendAIModelValidationFailed:
7677
print(
7778
f"WARNING: {CHAT_CACHE_FILE} is in an invalid format and was ignored.",
7879
file=sys.stderr,
@@ -164,7 +165,7 @@ def load(cls) -> Self:
164165
return cls()
165166
try:
166167
return cls.model_validate(raw)
167-
except ValidationError:
168+
except BackendAIModelValidationFailed:
168169
print(
169170
f"WARNING: {CHAT_CONFIG_FILE} is in an invalid format and was ignored. "
170171
"Re-register tokens with `./bai deployment chat-config set`.",
@@ -242,7 +243,7 @@ def load(cls) -> Self:
242243
return cls()
243244
try:
244245
return cls.model_validate(raw)
245-
except ValidationError:
246+
except BackendAIModelValidationFailed:
246247
print(
247248
f"WARNING: {CHAT_HISTORY_FILE} is in an invalid format and was ignored.",
248249
file=sys.stderr,

src/ai/backend/common/api_handlers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ai.backend.logging import BraceStyleAdapter
2727

2828
from .exception import (
29+
BackendAIModelValidationFailed,
2930
InvalidAPIHandlerDefinition,
3031
InvalidAPIParameters,
3132
MalformedRequestBody,
@@ -83,7 +84,13 @@ def convert_validation_error[T](func: Callable[..., T]) -> Callable[..., T]:
8384
def wrapped(*args: Any, **kwargs: Any) -> T:
8485
try:
8586
return func(*args, **kwargs)
87+
except BackendAIModelValidationFailed as e:
88+
raise InvalidAPIParameters(repr(e)) from e
8689
except ValidationError as e:
90+
# Defensive fallback: any Pydantic model that does NOT inherit
91+
# BackendAIModel would skip the auto-conversion override and
92+
# raise raw ``ValidationError``. Keep the handler so a stray
93+
# plain ``BaseModel`` subclass still produces a 400.
8794
raise InvalidAPIParameters(repr(e)) from e
8895

8996
return wrapped
@@ -268,7 +275,11 @@ async def extract_param_value(request: web.Request, input_param_type: Any) -> _P
268275
f"Parameter '{input_param_type}' must use one of QueryParam, PathParam, HeaderParam, MiddlewareParam, BodyParam"
269276
)
270277

278+
except BackendAIModelValidationFailed as e:
279+
raise InvalidAPIParameters(str(e)) from e
271280
except ValidationError as e:
281+
# Defensive fallback for plain ``BaseModel`` subclasses that skipped
282+
# the BackendAIModel auto-conversion.
272283
raise InvalidAPIParameters(str(e)) from e
273284

274285

src/ai/backend/common/clients/valkey_client/valkey_artifact/client.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from typing import Final, Self, cast
77

88
from glide import Batch, ExpirySet, ExpiryType
9-
from pydantic import ValidationError
109

1110
from ai.backend.common.clients.valkey_client.client import (
1211
AbstractValkeyClient,
@@ -17,7 +16,7 @@
1716
DownloadProgressData,
1817
FileDownloadProgressData,
1918
)
20-
from ai.backend.common.exception import BackendAIError
19+
from ai.backend.common.exception import BackendAIError, BackendAIModelValidationFailed
2120
from ai.backend.common.metrics.metric import DomainType, LayerType
2221
from ai.backend.common.resilience import (
2322
BackoffStrategy,
@@ -254,7 +253,7 @@ async def update_file_progress(
254253
previous_data_bytes
255254
)
256255
previous_current_bytes = previous_data.current_bytes
257-
except (ValidationError, UnicodeDecodeError):
256+
except (BackendAIModelValidationFailed, UnicodeDecodeError):
258257
log.warning("Failed to parse previous file data for progress update")
259258

260259
# Calculate bytes delta for artifact aggregation
@@ -361,7 +360,7 @@ async def get_file_progress(
361360

362361
try:
363362
return FileDownloadProgressData.model_validate_json(data_bytes)
364-
except (ValidationError, UnicodeDecodeError):
363+
except (BackendAIModelValidationFailed, UnicodeDecodeError):
365364
log.warning("Failed to parse file progress data")
366365
return None
367366

@@ -398,7 +397,7 @@ async def get_all_file_progress(
398397
value_bytes
399398
)
400399
file_progress_list.append(file_progress)
401-
except (ValidationError, UnicodeDecodeError):
400+
except (BackendAIModelValidationFailed, UnicodeDecodeError):
402401
log.warning(
403402
"Failed to parse file progress data for key: {}", key_bytes
404403
)

src/ai/backend/common/typed_validators.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from pydantic_core import core_schema
2929

3030
from ai.backend.common.exception import (
31+
BackendAIModelValidationFailed,
3132
JWTDecodeError,
3233
JWTExpiredError,
3334
JWTInvalidSignatureError,
@@ -580,5 +581,10 @@ def validate[TModel: BaseModel](self, token: str, model: type[TModel]) -> TModel
580581

581582
try:
582583
return model.model_validate(payload)
584+
except BackendAIModelValidationFailed as e:
585+
raise JWTPayloadValidationError(extra_msg=str(e)) from e
583586
except ValidationError as e:
587+
# Defensive fallback: ``model`` is a generic ``BaseModel`` subclass —
588+
# plain pydantic models without the BackendAIModel override still
589+
# raise raw ``ValidationError``.
584590
raise JWTPayloadValidationError(extra_msg=str(e)) from e

src/ai/backend/manager/api/utils.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from pydantic import Field, TypeAdapter, ValidationError
4040

4141
from ai.backend.common.api_handlers import BaseRequestModel, BaseResponseModel
42-
from ai.backend.common.exception import DeprecatedAPI
42+
from ai.backend.common.exception import BackendAIModelValidationFailed, DeprecatedAPI
4343
from ai.backend.common.json import load_json
4444
from ai.backend.logging import BraceStyleAdapter
4545
from ai.backend.manager.errors.api import (
@@ -223,8 +223,9 @@ async def wrapped(
223223
kwargs["query"] = query_params
224224
except (json.decoder.JSONDecodeError, yaml.YAMLError, yaml.MarkedYAMLError) as e:
225225
raise InvalidAPIParameters("Malformed body") from e
226-
except ValidationError as ex:
227-
first_error = ex.errors()[0]
226+
except BackendAIModelValidationFailed as ex:
227+
errors = (ex.extra_data or {}).get("errors") or []
228+
first_error = errors[0]
228229
# Format the first validation error as the message
229230
# The client may refer extra_data to access the full validation errors.
230231
metadata = {
@@ -237,7 +238,21 @@ async def wrapped(
237238
*(f"{k}={v!r}" for k, v in metadata.items()),
238239
]
239240
msg = f"{first_error['msg']} [{', '.join(metadata_formatted_items)}]"
240-
# To reuse the json serialization provided by pydantic, we call ex.json() and re-parse it.
241+
raise InvalidAPIParameters(msg, extra_data=ex.extra_data) from ex
242+
except ValidationError as ex:
243+
# Defensive fallback for plain ``BaseModel`` subclasses that
244+
# bypass the BackendAIModel auto-conversion override.
245+
first_error = ex.errors()[0]
246+
metadata = {
247+
"input": first_error["input"],
248+
}
249+
if loc := first_error["loc"]:
250+
metadata["loc"] = loc[0]
251+
metadata_formatted_items = [
252+
f"type={first_error['type']}",
253+
*(f"{k}={v!r}" for k, v in metadata.items()),
254+
]
255+
msg = f"{first_error['msg']} [{', '.join(metadata_formatted_items)}]"
241256
raise InvalidAPIParameters(msg, extra_data=load_json(ex.json())) from ex
242257
result = await handler(request, checked_params, *args, **kwargs)
243258
return ensure_stream_response_type(result)

0 commit comments

Comments
 (0)