Repository navigation
[P1][bug] ValidationError.__str__ discards the origin guard's remediation message (#113) #117
Description
Merged in #113 (cc7fd9b). Verified by execution against main on 2026-09-13 — confirmed live.
What is wrong
oilpriceapi/_url.py:49-63 composes a careful four-sentence remediation message and passes it to ValidationError(message=..., field="path", value=path).
oilpriceapi/exceptions.py:336-342 then throws it away:
def __str__(self) -> str:
message = super().__str__()
if self.field:
message = f"Validation error for '{self.field}'" # <-- overwrites
if self.value is not None:
message += f": invalid value '{self.value}'"
return message_reject() always sets field="path", so the branch always fires and the composed message is unconditionally discarded.
Repro
from oilpriceapi._url import resolve_api_url
from oilpriceapi.exceptions import ValidationError
for path in ["//evil.example/v1/prices", None]:
try:
resolve_api_url("https://api.oilpriceapi.com", path)
except ValidationError as e:
print("str(e) :", str(e))
print("e.message:", e.message)Measured:
str(e) = Validation error for 'path': invalid value '//evil.example/v1/prices'
e.message = Refusing to send this request: the path may not contain '//'. API paths must be
relative to the configured base URL (for example '/v1/prices/latest'). To talk to
a different host, construct a client with that base_url instead - a raw path may
not change the origin, because the API key would be sent to it.
# non-string path, value is None:
str(e) = Validation error for 'path'
Why it matters
Only e.message holds the guidance, and nothing in normal Python prints it — not str(e), not the traceback's last line, not logging.exception, not print(e), not a pytest failure repr. The developer who trips the new guard sees a bare "invalid value" and no explanation of why or what to do instead. For a non-string path (value is None) even the reason disappears.
#113's stated goal was that the caller "can see which value was refused" and learn the remedy. Half of that is currently unreachable.
Suggested fix
Either preserve the composed message when one was supplied:
def __str__(self) -> str:
message = super().__str__()
if self.field and message in ("", "Validation error"):
message = f"Validation error for '{self.field}'"
if self.value is not None:
message += f": invalid value '{self.value}'"
elif self.field:
message = f"{message} (field '{self.field}')"
return message…or have _url._reject fold the remediation text into the value/field-free form. The first is better: ValidationError.__str__ has the same message-eating behaviour for every other caller that passes both message= and field= (e.g. resources/alerts.py:531, resources/diesel.py coordinate checks), so this is not only a #113 problem.
Note also (minor, same file): ValidationError hard-codes status_code=422, so a purely local guard failure reports .is_client_error == True and a 422 for a request that was never sent.
Activity
fix(retry,errors): mark a 5xx-refused write ambiguous; stop eating th…
fix(url,retry,futures): un-break today's three regressions (#119, #118,