Skip to content

Commit bdc2667

Browse files
shahar1claude
andauthored
Drop envoy-data-plane/betterproto dependency from Python SDK (#39213)
EnvoyRateLimiter only needed a handful of protobuf message classes from envoy-data-plane, which pulls in the outdated betterproto==2.0.0b6 beta (a protobuf reimplementation) plus grpclib and transitive deps. That pin is the last blocker stopping Apache Airflow from un-suspending its Beam provider (apache/airflow#66952), and it forced a Python-version split in setup.py. The dependency was already fought rather than used: the RateLimitServiceStub bridge exists solely because betterproto emits async grpclib stubs that don't work with Beam's synchronous grpcio, and the wire is plain protobuf over grpcio regardless. Replace it with a minimal, self-contained rate_limit.proto compiled to a checked-in rate_limit_pb2.py (following the existing proto2_coder_test_messages_pb2.py precedent). Field numbers match Envoy's rls.proto/ratelimit.proto, so it stays wire-compatible with a real RLS server. This removes the conflict permanently for every downstream, deletes the py<3.11 split, and drops betterproto + grpclib from every container image. New wire-format tests pin the field numbers/enum values so a renumbering can't silently break live rate limiting (the mock-based tests would not). Note: the container base_image_requirements.txt files had the two direct packages removed to stay consistent with setup.py; a full `generatePythonRequirementsAll` regeneration should follow in CI to also prune now-orphaned transitives (grpclib, h2, multidict, ...) and refresh pins. Part of #37854 Unblocks apache/airflow#66952 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f35fa82 commit bdc2667

21 files changed

Lines changed: 198 additions & 62 deletions

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272

7373
## New Features / Improvements
7474

75+
* (Python) Removed the `envoy-data-plane` (and transitive `betterproto`) dependency; `EnvoyRateLimiter` now uses a small vendored protobuf definition instead, resolving dependency conflicts for downstream projects ([#37854](https://github.com/apache/beam/issues/37854)).
7576
* Dataflow Runner v2 has been renamed to Dataflow Portable Runner. Please refer to Dataflow [public documentation](https://docs.cloud.google.com/dataflow/docs/runner-v2) on when to enable Portable Runner.([#39000](https://github.com/apache/beam/issues/39000)).
7677
* (Java) Enabled state tag encoding v2 by default for new Dataflow Streaming Engine jobs. It can be disabled by passing `--experiments=disable_streaming_engine_state_tag_encoding_v2` or `--updateCompatibilityVersion=2.74.0` pipeline option. Note that the tag encoding version cannot change during a job update. Jobs using tag encoding v2 (enabled by default for new jobs on 2.75.0+) cannot be downgraded to Beam versions prior to 2.73.0, as only versions 2.73.0 and later support tag encoding v2. ([#38705](https://github.com/apache/beam/issues/38705)).
7778
* (Python) Added instrumentation to support off-the-shelf profiling agents when launching Python SDK Harness ([#38853](https://github.com/apache/beam/issues/38853)).

sdks/python/.yapfignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ apache_beam/runners/dataflow/internal/clients/dataflow/dataflow_v1b3_messages.py
2121
apache_beam/io/gcp/internal/clients/storage/storage_v1_client.py
2222
apache_beam/io/gcp/internal/clients/storage/storage_v1_messages.py
2323
apache_beam/coders/proto2_coder_test_messages_pb2.py
24+
apache_beam/io/components/rate_limit_pb2.py
2425
apache_beam/portability/api/*
2526

2627
# Avoid excessive wrapping.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0
5+
// (the "License"); you may not use this file except in compliance with
6+
// the License. You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
// Minimal, self-contained subset of the Envoy Rate Limit Service protocol,
17+
// used by EnvoyRateLimiter in rate_limiter.py. Only the fields the client
18+
// touches are declared. Field numbers MUST match Envoy's rls.proto and
19+
// ratelimit.proto so this is wire-compatible with a real Envoy RLS server
20+
// (protobuf carries only field numbers and types on the wire, not message or
21+
// package names). This lets Beam use its standard protobuf/grpcio stack
22+
// instead of pulling in envoy-data-plane and its betterproto dependency.
23+
//
24+
// Regenerate rate_limit_pb2.py after editing this file:
25+
// cd sdks/python
26+
// python -m grpc_tools.protoc -I. --python_out=. \
27+
// apache_beam/io/components/rate_limit.proto
28+
// then prepend the Apache license header and remove the generated
29+
// `runtime_version` guard so the module stays compatible with the full
30+
// protobuf runtime range Beam supports (see setup.py).
31+
32+
syntax = "proto3";
33+
34+
package apache_beam.io.components.ratelimit;
35+
36+
import "google/protobuf/duration.proto";
37+
38+
message RateLimitDescriptor {
39+
message Entry {
40+
string key = 1;
41+
string value = 2;
42+
}
43+
repeated Entry entries = 1;
44+
}
45+
46+
message RateLimitRequest {
47+
string domain = 1;
48+
repeated RateLimitDescriptor descriptors = 2;
49+
uint32 hits_addend = 3;
50+
}
51+
52+
message RateLimitResponse {
53+
enum Code {
54+
UNKNOWN = 0;
55+
OK = 1;
56+
OVER_LIMIT = 2;
57+
}
58+
message DescriptorStatus {
59+
Code code = 1;
60+
google.protobuf.Duration duration_until_reset = 4;
61+
}
62+
Code overall_code = 1;
63+
repeated DescriptorStatus statuses = 2;
64+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
# -*- coding: utf-8 -*-
19+
# Generated by the protocol buffer compiler. DO NOT EDIT!
20+
# NO CHECKED-IN PROTOBUF GENCODE
21+
# source: apache_beam/io/components/rate_limit.proto
22+
#
23+
# Regenerate with (from sdks/python):
24+
# python -m grpc_tools.protoc -I. --python_out=. \
25+
# apache_beam/io/components/rate_limit.proto
26+
# then re-apply this license header and delete the generated
27+
# `runtime_version` guard so the module works across the full protobuf
28+
# runtime range Beam supports (see setup.py).
29+
30+
"""Generated protocol buffer code."""
31+
from google.protobuf import descriptor as _descriptor
32+
from google.protobuf import descriptor_pool as _descriptor_pool
33+
from google.protobuf import symbol_database as _symbol_database
34+
from google.protobuf.internal import builder as _builder
35+
# @@protoc_insertion_point(imports)
36+
37+
_sym_db = _symbol_database.Default()
38+
39+
from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2
40+
41+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
42+
b'\n*apache_beam/io/components/rate_limit.proto\x12#apache_beam.io.components.ratelimit\x1a\x1egoogle/protobuf/duration.proto\"\x8b\x01\n\x13RateLimitDescriptor\x12O\n\x07\x65ntries\x18\x01 \x03(\x0b\x32>.apache_beam.io.components.ratelimit.RateLimitDescriptor.Entry\x1a#\n\x05\x45ntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\x86\x01\n\x10RateLimitRequest\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12M\n\x0b\x64\x65scriptors\x18\x02 \x03(\x0b\x32\x38.apache_beam.io.components.ratelimit.RateLimitDescriptor\x12\x13\n\x0bhits_addend\x18\x03 \x01(\r\"\x87\x03\n\x11RateLimitResponse\x12Q\n\x0coverall_code\x18\x01 \x01(\x0e\x32;.apache_beam.io.components.ratelimit.RateLimitResponse.Code\x12Y\n\x08statuses\x18\x02 \x03(\x0b\x32G.apache_beam.io.components.ratelimit.RateLimitResponse.DescriptorStatus\x1a\x96\x01\n\x10\x44\x65scriptorStatus\x12I\n\x04\x63ode\x18\x01 \x01(\x0e\x32;.apache_beam.io.components.ratelimit.RateLimitResponse.Code\x12\x37\n\x14\x64uration_until_reset\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"+\n\x04\x43ode\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x06\n\x02OK\x10\x01\x12\x0e\n\nOVER_LIMIT\x10\x02\x62\x06proto3'
43+
)
44+
45+
_globals = globals()
46+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
47+
_builder.BuildTopDescriptorsAndMessages(
48+
DESCRIPTOR, 'apache_beam.io.components.rate_limit_pb2', _globals)
49+
if not _descriptor._USE_C_DESCRIPTORS:
50+
DESCRIPTOR._loaded_options = None
51+
_globals['_RATELIMITDESCRIPTOR']._serialized_start = 116
52+
_globals['_RATELIMITDESCRIPTOR']._serialized_end = 255
53+
_globals['_RATELIMITDESCRIPTOR_ENTRY']._serialized_start = 220
54+
_globals['_RATELIMITDESCRIPTOR_ENTRY']._serialized_end = 255
55+
_globals['_RATELIMITREQUEST']._serialized_start = 258
56+
_globals['_RATELIMITREQUEST']._serialized_end = 392
57+
_globals['_RATELIMITRESPONSE']._serialized_start = 395
58+
_globals['_RATELIMITRESPONSE']._serialized_end = 786
59+
_globals['_RATELIMITRESPONSE_DESCRIPTORSTATUS']._serialized_start = 591
60+
_globals['_RATELIMITRESPONSE_DESCRIPTORSTATUS']._serialized_end = 741
61+
_globals['_RATELIMITRESPONSE_CODE']._serialized_start = 743
62+
_globals['_RATELIMITRESPONSE_CODE']._serialized_end = 786
63+
# @@protoc_insertion_point(module_scope)

sdks/python/apache_beam/io/components/rate_limiter.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,9 @@
2727
import time
2828

2929
import grpc
30-
from envoy_data_plane.envoy.extensions.common.ratelimit.v3 import RateLimitDescriptor
31-
from envoy_data_plane.envoy.extensions.common.ratelimit.v3 import RateLimitDescriptorEntry
32-
from envoy_data_plane.envoy.service.ratelimit.v3 import RateLimitRequest
33-
from envoy_data_plane.envoy.service.ratelimit.v3 import RateLimitResponse
34-
from envoy_data_plane.envoy.service.ratelimit.v3 import RateLimitResponseCode
3530

3631
from apache_beam.io.components import adaptive_throttler
32+
from apache_beam.io.components import rate_limit_pb2
3733
from apache_beam.metrics import Metrics
3834

3935
_LOGGER = logging.getLogger(__name__)
@@ -122,19 +118,18 @@ def __init__(
122118
self._lock = threading.Lock()
123119

124120
class RateLimitServiceStub(object):
125-
"""
126-
Wrapper for gRPC stub to be compatible with envoy_data_plane messages.
127-
128-
The envoy-data-plane package uses 'betterproto' which generates async stubs
129-
for 'grpclib'. As Beam uses standard synchronous 'grpcio',
130-
RateLimitServiceStub is a bridge class to use the betterproto Message types
131-
(RateLimitRequest) with a standard grpcio Channel.
121+
"""
122+
Minimal gRPC stub for the Envoy Rate Limit Service ShouldRateLimit method.
123+
124+
The method path is fixed by the Envoy RLS proto, so we bind it by hand
125+
against a standard synchronous grpcio Channel using the protobuf message
126+
types from rate_limit_pb2.
132127
"""
133128
def __init__(self, channel):
134129
self.ShouldRateLimit = channel.unary_unary(
135130
'/envoy.service.ratelimit.v3.RateLimitService/ShouldRateLimit',
136-
request_serializer=RateLimitRequest.SerializeToString,
137-
response_deserializer=RateLimitResponse.FromString,
131+
request_serializer=rate_limit_pb2.RateLimitRequest.SerializeToString,
132+
response_deserializer=rate_limit_pb2.RateLimitResponse.FromString,
138133
)
139134

140135
def init_connection(self):
@@ -171,10 +166,11 @@ def allow(self, hits_added: int = 1) -> bool:
171166
for d in self.descriptors:
172167
entries = []
173168
for k, v in d.items():
174-
entries.append(RateLimitDescriptorEntry(key=k, value=v))
175-
proto_descriptors.append(RateLimitDescriptor(entries=entries))
169+
entries.append(rate_limit_pb2.RateLimitDescriptor.Entry(key=k, value=v))
170+
proto_descriptors.append(
171+
rate_limit_pb2.RateLimitDescriptor(entries=entries))
176172

177-
request = RateLimitRequest(
173+
request = rate_limit_pb2.RateLimitRequest(
178174
domain=self.domain,
179175
descriptors=proto_descriptors,
180176
hits_addend=hits_added)
@@ -205,22 +201,21 @@ def allow(self, hits_added: int = 1) -> bool:
205201
e)
206202
time.sleep(_RPC_RETRY_DELAY_SECONDS)
207203

208-
if response.overall_code == RateLimitResponseCode.OK:
204+
if response.overall_code == rate_limit_pb2.RateLimitResponse.OK:
209205
self.requests_allowed.inc()
210206
throttled = True
211207
break
212-
elif response.overall_code == RateLimitResponseCode.OVER_LIMIT:
208+
elif response.overall_code == rate_limit_pb2.RateLimitResponse.OVER_LIMIT:
213209
self.requests_throttled.inc()
214210
# Ratelimit exceeded, sleep for duration until reset and retry
215211
# multiple rules can be set in the RLS config, so we need to find the
216212
# max duration
217213
sleep_s = 0.0
218214
if response.statuses:
219215
for status in response.statuses:
220-
if status.code == RateLimitResponseCode.OVER_LIMIT:
216+
if status.code == rate_limit_pb2.RateLimitResponse.OVER_LIMIT:
221217
dur = status.duration_until_reset
222-
# duration_until_reset is converted to timedelta by betterproto
223-
val = dur.total_seconds()
218+
val = dur.ToTimedelta().total_seconds()
224219
if val > sleep_s:
225220
sleep_s = val
226221

sdks/python/apache_beam/io/components/rate_limiter_test.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@
1616
#
1717

1818
import unittest
19-
from datetime import timedelta
2019
from unittest import mock
2120

2221
import grpc
23-
from envoy_data_plane.envoy.service.ratelimit.v3 import RateLimitResponse
24-
from envoy_data_plane.envoy.service.ratelimit.v3 import RateLimitResponseCode
25-
from envoy_data_plane.envoy.service.ratelimit.v3 import RateLimitResponseDescriptorStatus
22+
from google.protobuf.duration_pb2 import Duration
2623

24+
from apache_beam.io.components import rate_limit_pb2
2725
from apache_beam.io.components import rate_limiter
2826

27+
RateLimitResponse = rate_limit_pb2.RateLimitResponse
28+
2929

3030
class EnvoyRateLimiterTest(unittest.TestCase):
3131
def setUp(self):
@@ -45,7 +45,7 @@ def setUp(self):
4545
def test_allow_success(self, mock_channel):
4646
# Mock successful OK response
4747
mock_stub = mock.Mock()
48-
mock_response = RateLimitResponse(overall_code=RateLimitResponseCode.OK)
48+
mock_response = RateLimitResponse(overall_code=RateLimitResponse.OK)
4949
mock_stub.ShouldRateLimit.return_value = mock_response
5050

5151
# Inject mock stub
@@ -60,8 +60,7 @@ def test_allow_success(self, mock_channel):
6060
def test_allow_over_limit_retries_exceeded(self, mock_channel):
6161
# Mock OVER_LIMIT response
6262
mock_stub = mock.Mock()
63-
mock_response = RateLimitResponse(
64-
overall_code=RateLimitResponseCode.OVER_LIMIT)
63+
mock_response = RateLimitResponse(overall_code=RateLimitResponse.OVER_LIMIT)
6564
mock_stub.ShouldRateLimit.return_value = mock_response
6665

6766
self.limiter._stub = mock_stub
@@ -86,7 +85,7 @@ def test_allow_over_limit_retries_exceeded(self, mock_channel):
8685
def test_allow_rpc_error_retry(self, mock_channel):
8786
# Mock RpcError then Success
8887
mock_stub = mock.Mock()
89-
mock_response = RateLimitResponse(overall_code=RateLimitResponseCode.OK)
88+
mock_response = RateLimitResponse(overall_code=RateLimitResponse.OK)
9089

9190
# Side effect: Error, Error, Success
9291
error = grpc.RpcError()
@@ -123,11 +122,11 @@ def test_extract_duration_from_response(self, mock_random, mock_channel):
123122
mock_stub = mock.Mock()
124123

125124
# Valid until 5 seconds
126-
status = RateLimitResponseDescriptorStatus(
127-
code=RateLimitResponseCode.OVER_LIMIT,
128-
duration_until_reset=timedelta(seconds=5))
125+
status = RateLimitResponse.DescriptorStatus(
126+
code=RateLimitResponse.OVER_LIMIT,
127+
duration_until_reset=Duration(seconds=5))
129128
mock_response = RateLimitResponse(
130-
overall_code=RateLimitResponseCode.OVER_LIMIT, statuses=[status])
129+
overall_code=RateLimitResponse.OVER_LIMIT, statuses=[status])
131130

132131
mock_stub.ShouldRateLimit.return_value = mock_response
133132
self.limiter._stub = mock_stub
@@ -139,5 +138,44 @@ def test_extract_duration_from_response(self, mock_random, mock_channel):
139138
mock_sleep.assert_called_with(5.0)
140139

141140

141+
class RateLimitWireFormatTest(unittest.TestCase):
142+
"""Pins the on-the-wire layout of the vendored rate_limit_pb2 messages.
143+
144+
Wire compatibility with a real Envoy Rate Limit Service depends solely on
145+
field numbers and types (protobuf carries neither message nor package names
146+
on the wire), so these must stay in lockstep with Envoy's rls.proto and
147+
ratelimit.proto. The mock-based tests above would pass even if a field were
148+
renumbered; these golden-byte assertions fail if that ever happens.
149+
"""
150+
def test_request_wire_layout(self):
151+
request = rate_limit_pb2.RateLimitRequest(
152+
domain='d',
153+
descriptors=[
154+
rate_limit_pb2.RateLimitDescriptor(
155+
entries=[
156+
rate_limit_pb2.RateLimitDescriptor.Entry(
157+
key='k', value='v')
158+
])
159+
],
160+
hits_addend=1)
161+
# domain=1 (LEN "d"); descriptors=2 (LEN {entries=1 (LEN {key=1 "k",
162+
# value=2 "v"})}); hits_addend=3 (VARINT 1).
163+
self.assertEqual(
164+
request.SerializeToString().hex(), '0a016412080a060a016b1201761801')
165+
166+
def test_descriptor_status_wire_layout(self):
167+
status = rate_limit_pb2.RateLimitResponse.DescriptorStatus(
168+
code=rate_limit_pb2.RateLimitResponse.OVER_LIMIT,
169+
duration_until_reset=Duration(seconds=5))
170+
# code=1 (VARINT OVER_LIMIT=2); duration_until_reset=4 (LEN
171+
# Duration{seconds=1 (VARINT 5)}).
172+
self.assertEqual(status.SerializeToString().hex(), '080222020805')
173+
174+
def test_response_code_enum_values(self):
175+
self.assertEqual(int(rate_limit_pb2.RateLimitResponse.UNKNOWN), 0)
176+
self.assertEqual(int(rate_limit_pb2.RateLimitResponse.OK), 1)
177+
self.assertEqual(int(rate_limit_pb2.RateLimitResponse.OVER_LIMIT), 2)
178+
179+
142180
if __name__ == '__main__':
143181
unittest.main()

sdks/python/container/ml/py310/base_image_requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ attrs==26.1.0
3535
backports.tarfile==1.2.0
3636
beartype==0.22.9
3737
beautifulsoup4==4.15.0
38-
betterproto==2.0.0b7
3938
bs4==0.0.2
4039
build==1.5.0
4140
cachetools==6.2.6
@@ -52,7 +51,6 @@ distro==1.9.0
5251
dnspython==2.8.0
5352
docker==7.1.0
5453
docstring_parser==0.18.0
55-
envoy-data-plane==0.2.6
5654
exceptiongroup==1.3.1
5755
execnet==2.1.2
5856
fastavro==1.12.2

sdks/python/container/ml/py310/gpu_image_requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ attrs==26.1.0
3737
backports.tarfile==1.2.0
3838
beartype==0.22.9
3939
beautifulsoup4==4.15.0
40-
betterproto==2.0.0b7
4140
blake3==1.0.9
4241
bs4==0.0.2
4342
build==1.5.0
@@ -67,7 +66,6 @@ docker==7.1.0
6766
docstring_parser==0.18.0
6867
einops==0.8.2
6968
email-validator==2.3.0
70-
envoy-data-plane==0.2.6
7169
exceptiongroup==1.3.1
7270
execnet==2.1.2
7371
fastapi==0.138.1

sdks/python/container/ml/py311/base_image_requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ attrs==26.1.0
3434
backports.tarfile==1.2.0
3535
beartype==0.22.9
3636
beautifulsoup4==4.15.0
37-
betterproto==2.0.0b6
3837
bs4==0.0.2
3938
build==1.5.0
4039
cachetools==6.2.6
@@ -51,7 +50,6 @@ distro==1.9.0
5150
dnspython==2.8.0
5251
docker==7.1.0
5352
docstring_parser==0.18.0
54-
envoy_data_plane==1.0.3
5553
execnet==2.1.2
5654
fastavro==1.12.2
5755
fasteners==0.20

0 commit comments

Comments
 (0)