Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions sdk/python/feast/feature_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
from fastapi.logger import logger
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from google.protobuf.json_format import MessageToDict
from pydantic import BaseModel, field_validator

import feast
Expand All @@ -52,6 +51,7 @@
FeastError,
)
from feast.feast_object import FeastObject
from feast.feature_server_utils import convert_response_to_dict
from feast.feature_view_utils import get_feature_view_from_feature_store
from feast.permissions.action import WRITE, AuthzedAction
from feast.permissions.security_manager import assert_permissions
Expand Down Expand Up @@ -392,12 +392,9 @@ async def get_online_features(request: GetOnlineFeaturesRequest) -> Any:
)

response_dict = await run_in_threadpool(
MessageToDict,
response.proto,
preserving_proto_field_name=True,
float_precision=18,
convert_response_to_dict, response.proto
)
return response_dict
return JSONResponse(content=response_dict)

@app.post(
"/retrieve-online-documents",
Expand Down Expand Up @@ -434,12 +431,9 @@ async def retrieve_online_documents(
)

response_dict = await run_in_threadpool(
MessageToDict,
response.proto,
preserving_proto_field_name=True,
float_precision=18,
convert_response_to_dict, response.proto
)
return response_dict
return JSONResponse(content=response_dict)

@app.post("/push", dependencies=[Depends(inject_user_details)])
async def push(request: PushFeaturesRequest) -> Response:
Expand Down
141 changes: 141 additions & 0 deletions sdk/python/feast/feature_server_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Fast serialization utilities for Feature Server responses.

Matches the output format of MessageToDict with proto_json.patch() applied.
Values are serialized as native Python types (not wrapped dicts).
"""

import base64
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional

from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse
from feast.protos.feast.types.Value_pb2 import Value

logger = logging.getLogger(__name__)

# FieldStatus enum mapping (protos/feast/serving/ServingService.proto)
_STATUS_NAMES: Dict[int, str] = {
0: "INVALID",
1: "PRESENT",
2: "NULL_VALUE",
3: "NOT_FOUND",
4: "OUTSIDE_MAX_AGE",
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.


def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, Any]:
"""Convert GetOnlineFeaturesResponse to a JSON-serializable dict.

Matches the structure produced by MessageToDict(proto, preserving_proto_field_name=True)
with proto_json.patch() applied, with one intentional difference:

- double_val fields are returned as Python float objects (json.dumps uses Python 3.1+
shortest round-trip form, ~15-17 sig digits) rather than 18 fixed significant digits
(float_precision=18). Values are numerically identical; only the JSON string length
may differ. This is safe for all ML feature types and avoids unnecessary precision
overhead.
"""
result: Dict[str, Any] = {
"results": [
{
"values": [_value_to_native(v) for v in feature_vector.values],
"statuses": [
_STATUS_NAMES.get(s, "INVALID") for s in feature_vector.statuses
],
**(
{
"event_timestamps": [
_timestamp_to_str(ts)
for ts in feature_vector.event_timestamps
]
}
if feature_vector.event_timestamps
else {}
),
}
for feature_vector in response.results
]
}

if response.HasField("metadata"):
result["metadata"] = _metadata_to_dict(response.metadata)

if response.status:
result["status"] = response.status

return result
Comment thread
abhijeet-dhumal marked this conversation as resolved.


def _value_to_native(v: Value) -> Optional[Any]:
"""Convert a Value proto to a JSON-serializable Python type.

bytes_val and bytes_list_val are base64-encoded (RFC 4648) so that
JSONResponse can serialize them without TypeError. This matches standard
protobuf JSON encoding for bytes fields and is safe for all HTTP clients.
"""
which = v.WhichOneof("val")
if which is None or which == "null_val":
return None
# bytes must be base64-encoded for JSON serialization
elif which == "bytes_val":
return base64.b64encode(v.bytes_val).decode("ascii")
# RepeatedValue — nested Values that must be recursively converted
elif which in ("list_val", "set_val"):
return [_value_to_native(nested) for nested in getattr(v, which).val]
# Map<string, Value> — recursively convert nested Values
elif which in ("map_val", "struct_val"):
return {k: _value_to_native(vv) for k, vv in getattr(v, which).val.items()}
# MapList — list of Map<string, Value>
elif which in ("map_list_val", "struct_list_val"):
return [
{k: _value_to_native(vv) for k, vv in m.val.items()}
for m in getattr(v, which).val
]
# scalar_map_val has non-string keys; full conversion requires extra work and
# this type is not returned by standard get_online_features paths today.
elif which == "scalar_map_val":
logger.warning(
"scalar_map_val is not yet supported by convert_response_to_dict; value will be None"
)
return None
# bytes_list_val / bytes_set_val — base64-encode each element
elif which in ("bytes_list_val", "bytes_set_val"):
return [base64.b64encode(b).decode("ascii") for b in getattr(v, which).val]
# All other list/set types have scalar .val fields
elif "_list_" in which or "_set_" in which:
return list(getattr(v, which).val)
else:
return getattr(v, which)
Comment on lines +77 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 bytes_val values cause ORJSONResponse to raise TypeError

_value_to_native at feature_server_utils.py:68 returns raw bytes for bytes_val fields. Even if orjson were installed, orjson.dumps() cannot serialize Python bytes objects and raises TypeError: Type is not JSON serializable: bytes. The old code path returned the dict to FastAPI which processed it through jsonable_encoder (capable of handling bytes). The switch to ORJSONResponse bypasses that encoder, causing a regression for any response containing bytes-typed feature values.

Prompt for agents
In _value_to_native (feature_server_utils.py), when the value type is bytes_val, the raw bytes object is returned. orjson cannot serialize bytes. You need to either: (1) base64-encode bytes values before returning them (e.g. using base64.b64encode(val).decode('ascii')), or (2) not use ORJSONResponse and instead use a serializer that handles bytes. Note that bytes_list_val (handled by the _list_ branch) would also produce a list of bytes objects with the same issue.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abhijeet-dhumal bytes_val is still problem since you are using JSONResponse

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this, Nikhil! Now bytes_val and bytes_list_val now base64-encode the output before returning, so JSONResponse / json.dumps can serialize them without TypeError.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review!



def _timestamp_to_str(ts) -> str:
"""Convert protobuf Timestamp to RFC 3339 format with Z suffix.

Uses adaptive precision to match MessageToDict output:
- No fractional seconds when nanos == 0
- 3 digits (milliseconds) when nanos % 1_000_000 == 0
- 6 digits (microseconds) when nanos % 1_000 == 0
- 9 digits (nanoseconds) otherwise
"""
if ts.seconds == 0 and ts.nanos == 0:
return "1970-01-01T00:00:00Z"
dt = datetime.fromtimestamp(ts.seconds, tz=timezone.utc)
base = dt.strftime("%Y-%m-%dT%H:%M:%S")
nanos = ts.nanos
if nanos == 0:
return base + "Z"
elif nanos % 1_000_000 == 0:
return base + ".%03dZ" % (nanos // 1_000_000)
elif nanos % 1_000 == 0:
return base + ".%06dZ" % (nanos // 1_000)
else:
return base + ".%09dZ" % nanos


def _metadata_to_dict(metadata) -> Dict[str, Any]:
"""Convert FeatureResponseMeta to dict (matches proto_json.patch() format)."""
result: Dict[str, Any] = {}
if metadata.HasField("feature_names"):
result["feature_names"] = list(metadata.feature_names.val)
return result
Loading
Loading