Skip to content

Commit 9823588

Browse files
karlwaldmanclaude
andauthored
fix(config): honour timeout=0 and base_url; stop crashing on max_retries=0 (#120, #121) (#126)
Both halves of the constructor argument handling #115 started and did not finish, plus the version bump and changelog that change needed. #120 -- #115 replaced two of the three `or` defaults and left the third, on the line directly above its own fix, in both clients: self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") # unchanged self.timeout = timeout or self.DEFAULT_TIMEOUT # unchanged self.max_retries = (... if max_retries is None else ...) # fixed self.retry_on = (... if retry_on is None else ...) # fixed `timeout=0` is a real httpx timeout meaning "fail immediately" and is what `float(os.getenv("OPA_TIMEOUT", "0"))` produces. It silently became 30, and the resulting hang is very hard to attribute back to the constructor. A negative timeout was passed down to httpx unvalidated. `base_url=""` pointed the client at PRODUCTION, which also pins the #113 origin guard to an origin the caller did not choose -- the worst available answer for an input nobody meant. Both are now explicit None checks with validation, matching the two lines below them. `timeout=None` and `base_url=None` still take the defaults. #121 -- `max_retries=0` and `max_retries=3.0` started raising `ConfigurationError` AT CLIENT CONSTRUCTION, having constructed fine in 1.13.0, with no version bump and no changelog. The validation is right; the delivery was not. This fails at startup, so it takes a whole process down rather than degrading one call, and `int(os.getenv("OPA_MAX_RETRIES", "0"))` is the common way to produce it. Recommendation taken: accept them again, do not break. 0 -> 1 attempt, with a DeprecationWarning saying the argument counts total ATTEMPTS, not retries after the first 3.0 -> 3, with a DeprecationWarning -1 -> still ConfigurationError 2.5 -> still ConfigurationError (not a whole number of attempts) "3" -> still ConfigurationError True -> still ConfigurationError (a typo hazard; it would mean 1) The bug #104 fixed does not come back: 0 resolves to ONE attempt, never 3, and `test_zero_max_retries_does_not_go_back_to_three` counts what reaches the transport. Version bumped 1.13.0 -> 1.14.0 in `version.py` and `pyproject.toml`, with a CHANGELOG entry covering both, including an "Upgrading" note: nothing that worked in 1.13.0 raises in 1.14.0, but `timeout=0` and `max_retries=0` now mean what they say instead of 30 and 3. Two tests added in #115 are updated rather than deleted: `test_invalid_max_retries_fails_loudly` drops `0` from its parametrize and keeps the negatives; `test_async_invalid_max_retries_fails_loudly` uses -1. The new contract for 0 is pinned in tests/unit/test_constructor_config_validation.py (43 tests, every one run against BOTH clients). Proven red against `origin/main` source: 22 failed / 21 passed. Suite: 112 failed / 673 passed / 63 skipped, against a measured baseline of 112 failed / 630 passed / 63 skipped on clean main (the 112 are pre-existing, all missing pytest-asyncio and respx in the environment). Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b4b2a15 commit 9823588

8 files changed

Lines changed: 376 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22

33
All notable changes to the OilPriceAPI Python SDK will be documented in this file.
44

5+
## [1.14.0] - Unreleased
6+
7+
### Fixed
8+
9+
- **`timeout=0` is honoured instead of silently becoming 30.** The constructor
10+
used `timeout or self.DEFAULT_TIMEOUT`, so an explicit zero -- a real httpx
11+
timeout meaning "fail immediately", and what
12+
`float(os.getenv("OPA_TIMEOUT", "0"))` produces -- was discarded. The caller
13+
got a 30-second timeout and a hang that is very hard to attribute back to the
14+
constructor. Same defect, and the same line, as the `or` defaults fixed in
15+
1.13.x for `max_retries` and `retry_on`. Both clients.
16+
- **`base_url=""` no longer points the client at production.** It now raises
17+
`ConfigurationError`. Whatever an empty string meant, silently talking to the
18+
live API -- and pinning the request-origin guard to an origin the caller did
19+
not choose -- was the worst available answer.
20+
21+
### Changed
22+
23+
- **`max_retries=0` is accepted again, with a `DeprecationWarning`, and means
24+
one attempt.** It was briefly rejected with `ConfigurationError` at client
25+
construction, which takes a process down at startup for a value that
26+
constructed fine in 1.13.0. `max_retries` counts total **attempts**, not
27+
retries after the first, so `0` now resolves to `1` -- one attempt, no
28+
retries -- and the warning says so. It does **not** go back to silently
29+
meaning 3. `0` will be refused in the next major version; pass
30+
`max_retries=1` to say "no retries" explicitly.
31+
- **An integral float `max_retries` (`3.0`) is coerced with a
32+
`DeprecationWarning`** instead of raising. That is what a JSON or YAML config
33+
round-trip produces for an integer.
34+
- Still refused, because no coercion is obviously right: a negative
35+
`max_retries`, a non-integral float (`2.5`), a string, and `bool` (`True`
36+
would silently mean one attempt).
37+
38+
### Added
39+
40+
- `timeout` is validated: a negative or non-numeric value raises
41+
`ConfigurationError` instead of being passed down to httpx unvalidated.
42+
43+
### Upgrading
44+
45+
Nothing that worked in 1.13.0 raises in 1.14.0. Two silent behaviours change:
46+
`timeout=0` now means zero rather than 30, and `max_retries=0` now means one
47+
attempt rather than three. If you were relying on either of those defaults,
48+
pass the value you want explicitly.
49+
550
## [1.13.0] - 2026-08-23
651

752
### Fixed

‎oilpriceapi/async_client.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@
4444
)
4545
from .models import HistoricalPrice, HistoricalResponse, MarketBrief, Price
4646
from .resource_validators import format_date
47-
from .retry import RetryStrategy, mark_ambiguous_write, validated_max_retries
47+
from .retry import (
48+
RetryStrategy,
49+
mark_ambiguous_write,
50+
validated_base_url,
51+
validated_max_retries,
52+
validated_timeout,
53+
)
4854

4955

5056
class AsyncOilPriceAPI:
@@ -97,8 +103,13 @@ def __init__(
97103
)
98104

99105
# Configuration
100-
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
101-
self.timeout = timeout or self.DEFAULT_TIMEOUT
106+
# Explicit None checks, not `or`, on every one of these four lines.
107+
# #115 fixed the two below and left these two, so `timeout=0` silently
108+
# became 30 and `base_url=""` silently became production (#120).
109+
self.base_url = (
110+
self.DEFAULT_BASE_URL if base_url is None else validated_base_url(base_url)
111+
)
112+
self.timeout = self.DEFAULT_TIMEOUT if timeout is None else validated_timeout(timeout)
102113
# Explicit None checks, not `or` (#104) -- see OilPriceAPI.__init__.
103114
self.max_retries = (
104115
self.DEFAULT_MAX_RETRIES if max_retries is None else validated_max_retries(max_retries)

‎oilpriceapi/client.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@
4545
from .resources.subscriptions import SubscriptionsResource
4646
from .resources.webhooks import WebhooksResource
4747
from .resources.well_production import WellProductionResource
48-
from .retry import RetryStrategy, mark_ambiguous_write, validated_max_retries
48+
from .retry import (
49+
RetryStrategy,
50+
mark_ambiguous_write,
51+
validated_base_url,
52+
validated_max_retries,
53+
validated_timeout,
54+
)
4955

5056

5157
class OilPriceAPI:
@@ -118,11 +124,14 @@ def __init__(
118124
)
119125

120126
# Configuration
121-
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
122-
self.timeout = timeout or self.DEFAULT_TIMEOUT
123-
# Explicit None checks, not `or`: an explicit max_retries=0 used to
124-
# become 3 and an explicit retry_on=[] used to become the default status
125-
# list, silently discarding what the caller asked for (#104).
127+
# Explicit None checks, not `or`, on every one of these four lines.
128+
# #115 fixed the two below and left these two, so `timeout=0` silently
129+
# became 30 and `base_url=""` silently became production (#120).
130+
self.base_url = (
131+
self.DEFAULT_BASE_URL if base_url is None else validated_base_url(base_url)
132+
)
133+
self.timeout = self.DEFAULT_TIMEOUT if timeout is None else validated_timeout(timeout)
134+
# max_retries=0 and retry_on=[] used to be discarded the same way (#104).
126135
self.max_retries = (
127136
self.DEFAULT_MAX_RETRIES if max_retries is None else validated_max_retries(max_retries)
128137
)

‎oilpriceapi/retry.py‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import random
5+
import warnings
56
from typing import List, Mapping, Optional
67

78
logger = logging.getLogger(__name__)
@@ -22,15 +23,61 @@ def validated_max_retries(value: object) -> int:
2223
(``for attempt in range(self.max_retries)``). One attempt is the minimum;
2324
zero attempts would send nothing. This used to be swallowed by an ``or``
2425
default, which silently turned an explicit 0 into 3.
26+
27+
``0`` and an integral float are accepted with a ``DeprecationWarning``
28+
rather than refused (#121). Both constructed fine in 1.13.0 -- ``0`` is the
29+
natural spelling of "do not retry" and ``3.0`` is what a JSON or YAML
30+
config round-trip produces for an integer -- and this failure happens at
31+
CLIENT CONSTRUCTION, so refusing them takes the whole process down at
32+
startup rather than degrading one call. The warning says what the argument
33+
actually counts, which is the thing the caller needs to learn; the silent
34+
``0 -> 3`` that #104 removed does not come back.
35+
36+
Still refused, because no coercion is obviously right: negatives,
37+
non-numerics, ``bool`` (a typo hazard: ``True`` would mean one attempt),
38+
and a non-integral float.
2539
"""
2640
from .exceptions import ConfigurationError
2741

28-
if isinstance(value, bool) or not isinstance(value, int):
42+
if isinstance(value, bool):
43+
raise ConfigurationError(
44+
"max_retries must be an int counting total attempts, got bool. "
45+
"Pass max_retries=1 for a single attempt with no retries."
46+
)
47+
48+
if isinstance(value, float):
49+
if not value.is_integer():
50+
raise ConfigurationError(
51+
f"max_retries counts total attempts and must be a whole number, "
52+
f"got {value}. Pass max_retries=1 for a single attempt with no "
53+
f"retries."
54+
)
55+
warnings.warn(
56+
f"max_retries={value!r} is a float; it counts total ATTEMPTS and is "
57+
f"being used as {int(value)}. Pass an int.",
58+
DeprecationWarning,
59+
stacklevel=3,
60+
)
61+
value = int(value)
62+
63+
if not isinstance(value, int):
2964
raise ConfigurationError(
3065
f"max_retries must be an int counting total attempts, got "
3166
f"{type(value).__name__}. Pass max_retries=1 for a single attempt "
3267
f"with no retries."
3368
)
69+
70+
if value == 0:
71+
warnings.warn(
72+
"max_retries counts total ATTEMPTS, not retries after the first, so "
73+
"max_retries=0 is being treated as 1 -- one attempt, no retries. "
74+
"Pass max_retries=1 to say that explicitly; 0 will be refused in a "
75+
"future major version.",
76+
DeprecationWarning,
77+
stacklevel=3,
78+
)
79+
return 1
80+
3481
if value < 1:
3582
raise ConfigurationError(
3683
f"max_retries counts total attempts and must be at least 1, got {value}. "
@@ -39,6 +86,58 @@ def validated_max_retries(value: object) -> int:
3986
return value
4087

4188

89+
def validated_timeout(value: object) -> float:
90+
"""Validate an explicit ``timeout`` (seconds).
91+
92+
``0`` is a real request timeout to httpx -- fail immediately rather than
93+
wait -- and is what ``float(os.getenv("OPA_TIMEOUT", "0"))`` produces. It
94+
used to be swallowed by ``timeout or self.DEFAULT_TIMEOUT`` and silently
95+
became 30, and the resulting hang is very hard to attribute back to the
96+
constructor (#120).
97+
98+
A negative value used to be passed straight down to httpx unvalidated.
99+
"""
100+
from .exceptions import ConfigurationError
101+
102+
if isinstance(value, bool) or not isinstance(value, (int, float)):
103+
raise ConfigurationError(
104+
f"timeout must be a number of seconds, got {type(value).__name__}. "
105+
f"Pass timeout=None for the {30}s default."
106+
)
107+
if value != value: # NaN
108+
raise ConfigurationError("timeout must be a number of seconds, got NaN.")
109+
if value < 0:
110+
raise ConfigurationError(
111+
f"timeout must be zero or positive, got {value}. Use timeout=0 to "
112+
f"fail immediately, or timeout=None for the default."
113+
)
114+
return value
115+
116+
117+
def validated_base_url(value: object) -> str:
118+
"""Validate an explicit ``base_url``.
119+
120+
``(base_url or DEFAULT).rstrip("/")`` sent a client constructed with
121+
``base_url=""`` to PRODUCTION. Whatever the caller meant, that is the worst
122+
available answer -- and every request-origin guard downstream then pins to
123+
an origin they did not choose (#120).
124+
"""
125+
from .exceptions import ConfigurationError
126+
127+
if not isinstance(value, str):
128+
raise ConfigurationError(
129+
f"base_url must be a string, got {type(value).__name__}. "
130+
f"Pass base_url=None for the default."
131+
)
132+
trimmed = value.strip().rstrip("/")
133+
if not trimmed:
134+
raise ConfigurationError(
135+
"base_url is empty. Pass base_url=None for the default "
136+
"(https://api.oilpriceapi.com) rather than an empty string."
137+
)
138+
return trimmed
139+
140+
42141
def mark_ambiguous_write(error, method: Optional[str]):
43142
"""Tell the caller the write was sent once and its outcome is unknown."""
44143
error.ambiguous_write = True

‎oilpriceapi/version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
Used in __init__.py, client.py, and async_client.py.
66
"""
77

8-
__version__ = "1.13.0"
8+
__version__ = "1.14.0"
99
SDK_VERSION = __version__
1010
SDK_NAME = "oilpriceapi-python"

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66

77
[project]
88
name = "oilpriceapi"
9-
version = "1.13.0"
9+
version = "1.14.0"
1010
description = "Official Python SDK for source-timestamped OilPriceAPI energy data"
1111
authors = [
1212
{name = "OilPriceAPI", email = "support@oilpriceapi.com"}

0 commit comments

Comments
 (0)