Skip to content

Commit 275b6d4

Browse files
committed
uploader: harden upload preflight and retry handling
1 parent 9c9cb5a commit 275b6d4

11 files changed

Lines changed: 271 additions & 62 deletions

docs/Uploader_Reference.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,9 @@ This mode requires a host Python 3.10 or newer on Linux, macOS, and Windows at
183183
performs its enrichment, validation, preflight split at the conservative
184184
`4_718_592`-byte (4.5 MiB) threshold, and upload/retries independently. Every
185185
worker can process test, coverage, or telemetry payloads; chunks and other
186-
derived requests belonging to one source remain sequential. Non-standard JSON
187-
numbers (`NaN` and positive/negative `Infinity`) are rejected before HTTP. The
186+
derived requests belonging to one source remain sequential. Test and telemetry
187+
JSON reject non-standard numbers (`NaN` and positive/negative `Infinity`) before
188+
HTTP; coverage bodies remain opaque JSON/msgpack multipart parts. The
188189
rule-level `workers` value defaults to `4`; `DD_TEST_OPTIMIZATION_WORKERS`
189190
overrides it at runtime and `--workers=<positive-integer>` has highest
190191
precedence. Leave
@@ -691,10 +692,14 @@ payload discovery/quiescence before proceeding.
691692
- Each logical request makes at most four total attempts: the initial attempt
692693
plus up to three retries, normally separated by 2 seconds.
693694
- Connection failures, timeouts, HTTP `408`, HTTP `429`, and HTTP `5xx` are
694-
retryable. `Retry-After` is honored when the backend supplies it.
695-
- Other HTTP `4xx` responses are terminal after the first attempt. In
696-
particular, `413` indicates that the preventive split contract was violated;
697-
it is never retried and never triggers adaptive splitting.
695+
retryable. `Retry-After` is honored when the backend supplies it, with a
696+
60-second safety cap per retry so a response cannot stall a worker
697+
indefinitely.
698+
- Other HTTP `4xx` responses are terminal after the first attempt. For test
699+
payloads, `413` indicates that the preventive split contract was violated.
700+
Coverage and telemetry are not split, so their `413` failures use the
701+
`upload_http_413` reason instead. No `413` is retried or triggers adaptive
702+
splitting.
698703
- JSON, gzip, telemetry, and multipart bodies are prepared once per logical
699704
request and replayed byte-for-byte for every retry.
700705
- The temporary legacy Bash/curl and PowerShell implementations remain

docs/python_parallel_uploader_migration_plan.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ Current checkpoint:
4646
schema can be shared by workers while each validation keeps independent
4747
errors, warnings, and counters; the existing validator CLI remains intact;
4848
- a standard-library HTTP transport now implements the normalized
49-
four-attempt retry policy, terminal `413`/permanent `4xx`, `Retry-After`,
50-
separate connect and socket-I/O timeouts, bounded response diagnostics,
51-
system TLS verification, snapshotted proxy/`NO_PROXY` settings, gzip, and
52-
replayable exact-length JSON/multipart bodies. Its local macOS compatibility
53-
spike passes; HTTPS handshakes and Linux/Windows lanes remain open gates;
49+
four-attempt retry policy, terminal `413`/permanent `4xx`, `Retry-After`
50+
bounded to 60 seconds per retry, separate connect and socket-I/O timeouts,
51+
bounded response diagnostics, system TLS verification, snapshotted
52+
proxy/`NO_PROXY` settings, gzip, and replayable exact-length JSON/multipart
53+
bodies. Its local macOS compatibility spike passes; HTTPS handshakes and
54+
Linux/Windows lanes remain open gates;
5455
- CODEOWNERS discovery and parsing now produce one immutable matcher before
5556
worker startup. It preserves current discovery order, last-match-wins,
5657
explicit empty-owner rules, source candidate normalization, producer-owned
@@ -81,8 +82,10 @@ Current checkpoint:
8182
enrichment-tag checks, preventive splitting, gzip preparation, telemetry
8283
augmentation, and the upload path's URL/header/body-size request validation,
8384
then stops before HTTP and source cleanup;
84-
- custom agentless and EVP base URLs now validate numeric in-range ports during
85-
preflight, so dry-run rejects an endpoint that upload mode could not open;
85+
- custom agentless and EVP base URLs now validate numeric in-range ports,
86+
printable ASCII, and percent escapes during preflight. Effective HTTP(S)
87+
proxy configuration is also validated before workers start, so dry-run
88+
rejects request configuration that upload mode could not use;
8689
- every JSON document parsed or generated by the Python uploader now rejects
8790
non-standard `NaN` and positive/negative `Infinity` values before HTTP;
8891
- the deterministic compact-JSON splitter is connected to the worker path and
@@ -134,7 +137,7 @@ Current checkpoint:
134137
Validation checkpoint (`2026-08-29`, macOS arm64):
135138

136139
- `python3 -m unittest discover -s tools/tests/python -p 'test*_tools.py'`:
137-
446 tests executed; 445 pass and one platform-specific test is skipped on
140+
450 tests executed; 449 pass and one platform-specific test is skipped on
138141
macOS with ShellCheck and PowerShell installed;
139142
- `./bazelw test //tools/tests/core:tests
140143
//tools/tests/python:python_tools_test
@@ -751,7 +754,8 @@ dry-run modes.
751754
| Optional telemetry provider/facts augmentation cannot be produced | Warning | Send the valid primary body when possible; omit only the unavailable optional derived request | Retain current best-effort augmentation behavior |
752755
| Request construction fails | File failure | No request, or stop remaining derived requests | Retain source |
753756
| Retry budget is exhausted or a terminal HTTP status is received | File failure | Stop later chunks/derived requests for that source | Retain source; unrelated workers continue |
754-
| HTTP `413` | Immediate file failure | Do not retry and do not split again | Retain source with `payload_limit_contract_mismatch` |
757+
| Test HTTP `413` after preventive splitting | Immediate file failure | Do not retry and do not split again | Retain source with `payload_limit_contract_mismatch` |
758+
| Coverage or telemetry HTTP `413` | Immediate file failure | Do not retry; structural splitting is unsupported for these types | Retain source with `upload_http_413` |
755759
| Source deletion fails after successful delivery | Warning | Do not repeat the successful request | Report the source as retained without converting intake success into upload failure |
756760

757761
Warnings must be captured in debug/report diagnostics without incrementing
@@ -1043,7 +1047,7 @@ Initial policy:
10431047
- 60-second request timeout;
10441048
- retry transport failures that can be transient;
10451049
- retry HTTP `408`, `429`, and `5xx` statuses;
1046-
- honor a valid `Retry-After` header;
1050+
- honor a valid `Retry-After` header up to a 60-second safety cap per retry;
10471051
- otherwise use the documented two-second retry delay for initial parity;
10481052
- do not retry permanent `4xx` responses;
10491053
- specifically do not retry `413`;
@@ -1741,6 +1745,8 @@ validation describe and exercise the same implementation.
17411745
- Timeout then success.
17421746
- `408` retry.
17431747
- `429` with `Retry-After` retry.
1748+
- Normal, excessive numeric, far-future date, and overflow-sized `Retry-After`
1749+
values remain finite and do not exceed the 60-second cap.
17441750
- `500`, `502`, `503`, and `504` retry.
17451751
- Sustained retryable failure exhausts the budget.
17461752
- Permanent `400`, `401`, `403`, and `404` are not retried.

tools/core/uploader_py/application.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from .reporting import AggregateReport, LegacyReportContext
5353
from .resources import ResourceInputs, load_resources
5454
from .temporary import TemporaryDirectoryError
55+
from .transport import HttpTransportError
5556
from .worker_pool import WorkerPoolError
5657

5758

@@ -61,6 +62,7 @@
6162
ExpectedTargetsError,
6263
FreshnessError,
6364
TemporaryDirectoryError,
65+
HttpTransportError,
6466
WorkerPoolError,
6567
WorkspaceLockError,
6668
)

tools/core/uploader_py/coordinator.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from .resources import LoadedResources
3232
from .telemetry import build_telemetry_plan
3333
from .temporary import invocation_temporary_directory
34-
from .transport import HttpTransport
34+
from .transport import HttpTransport, validate_proxy_environment
3535
from .worker_pool import (
3636
WorkerPoolInterrupted,
3737
WorkerPoolRun,
@@ -97,6 +97,7 @@ def execute_discovery(
9797
) -> CoordinatorOutcome:
9898
"""Run already-authorized tasks; workers never mutate coordinator state."""
9999
started = clock()
100+
validate_proxy_environment(settings.proxy_environment)
100101
matcher = load_codeowners_matcher(
101102
explicit_path=settings.codeowners_file,
102103
workspace_root=settings.workspace,

tools/core/uploader_py/endpoints.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010

1111
from dataclasses import dataclass
1212
import re
13-
from urllib.parse import urlsplit
13+
from urllib.parse import SplitResult, urlsplit
1414

1515
from .config import ConfigError, UploaderConfig
1616

1717

1818
_VALID_HOSTNAME_RE = re.compile(
1919
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?([.][a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"
2020
)
21+
_INVALID_PERCENT_ESCAPE_RE = re.compile(r"%(?![0-9A-Fa-f]{2})")
2122

2223

2324
@dataclass(frozen=True)
@@ -60,32 +61,41 @@ def normalize_dd_site(raw_site: str) -> str:
6061
return site
6162

6263

64+
def parse_http_url(raw_url: str) -> SplitResult:
65+
"""Parse one network-ready absolute HTTP(S) URL.
66+
67+
urllib accepts some values during parsing that ``http.client`` rejects only
68+
when it starts a request. Keeping this small preflight shared by endpoint
69+
configuration and request preparation makes dry-run reject those values too.
70+
"""
71+
if not isinstance(raw_url, str) or not raw_url:
72+
raise ValueError("HTTP URL must be a non-empty string")
73+
if any(ord(character) < 33 or ord(character) > 126 for character in raw_url):
74+
raise ValueError("HTTP URL must contain only printable ASCII characters")
75+
if _INVALID_PERCENT_ESCAPE_RE.search(raw_url):
76+
raise ValueError("HTTP URL contains an invalid percent escape")
77+
78+
parsed = urlsplit(raw_url)
79+
hostname = parsed.hostname or ""
80+
# Accessing port performs urllib's numeric and range validation.
81+
_ = parsed.port
82+
if parsed.scheme.lower() not in {"http", "https"} or not hostname:
83+
raise ValueError("HTTP URL must be absolute")
84+
return parsed
85+
86+
6387
def _validated_base_url(raw_url: str, variable_name: str) -> str:
6488
"""Validate an endpoint override without echoing sensitive URL components."""
6589
base = raw_url.rstrip("/")
6690
try:
67-
parsed = urlsplit(base)
68-
hostname = parsed.hostname or ""
69-
# Accessing port performs urllib's numeric and range validation.
70-
_ = parsed.port
71-
valid = (
72-
parsed.scheme.lower() in {"http", "https"}
73-
and bool(hostname)
74-
and not any(
75-
ord(character) <= 32 or ord(character) == 127
76-
for character in hostname
77-
)
78-
)
91+
parsed = parse_http_url(base)
7992
except (TypeError, ValueError):
80-
valid = False
8193
parsed = None
82-
if not valid:
94+
if parsed is None:
8395
raise ConfigError(f"{variable_name} must be an absolute HTTP(S) URL")
84-
if parsed is not None and (
85-
parsed.username is not None or parsed.password is not None
86-
):
96+
if parsed.username is not None or parsed.password is not None:
8797
raise ConfigError(f"{variable_name} must not contain credentials/userinfo")
88-
if parsed is not None and (parsed.query or parsed.fragment):
98+
if parsed.query or parsed.fragment:
8999
raise ConfigError(f"{variable_name} must not contain a query or fragment")
90100
return base
91101

tools/core/uploader_py/file_worker.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ def _process_test(
467467
if not result.succeeded:
468468
failure_code, failure_message = _http_failure(
469469
result,
470+
payload_type=task.payload_type,
470471
payload_limit_context=(
471472
f"chunk={chunk.index}/{len(chunks)} "
472473
f"uncompressed_bytes={chunk.size_bytes} "
@@ -583,7 +584,10 @@ def _process_coverage(
583584
f"coverage request completed after {result.attempts} attempt(s)",
584585
)
585586
if not result.succeeded:
586-
failure_code, failure_message = _http_failure(result)
587+
failure_code, failure_message = _http_failure(
588+
result,
589+
payload_type=task.payload_type,
590+
)
587591
return FileResult(
588592
status=FileStatus.FAILED,
589593
requests_attempted=result.attempts,
@@ -718,7 +722,10 @@ def _process_telemetry(
718722
f"telemetry request completed after {result.attempts} attempt(s)",
719723
)
720724
if not result.succeeded:
721-
failure_code, failure_message = _http_failure(result)
725+
failure_code, failure_message = _http_failure(
726+
result,
727+
payload_type=task.payload_type,
728+
)
722729
return FileResult(
723730
status=FileStatus.FAILED,
724731
requests_attempted=requests_attempted,
@@ -995,15 +1002,20 @@ def _cleanup_source(path: Path, keep_payloads: bool) -> tuple[bool, str | None]:
9951002
def _http_failure(
9961003
result: HttpResult,
9971004
*,
1005+
payload_type: PayloadType,
9981006
payload_limit_context: str | None = None,
9991007
) -> tuple[str, str]:
10001008
if result.status_code == 413:
1001-
if payload_limit_context:
1009+
if payload_type is PayloadType.TEST and payload_limit_context:
10021010
return (
10031011
"payload_limit_contract_mismatch",
10041012
f"HTTP 413 after preventive split; {payload_limit_context}",
10051013
)
1006-
return "upload_http_413", "HTTP 413 after preventive split"
1014+
return (
1015+
"upload_http_413",
1016+
f"HTTP 413 for unsplit {payload_type.value} payload; "
1017+
f"{payload_type.value} splitting is not supported",
1018+
)
10071019
if result.status_code is not None:
10081020
return "upload_http_error", f"HTTP {result.status_code}"
10091021
return "upload_transport_error", result.transport_error or "transport error"

0 commit comments

Comments
 (0)