Skip to content

Commit f751d40

Browse files
committed
Merge remote-tracking branch 'origin/main' into openapi-update-27777677089
# Conflicts: # CHANGELOG.md
2 parents 13963ae + 37f628c commit f751d40

10 files changed

Lines changed: 394 additions & 61 deletions

File tree

.github/workflows/check-release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ jobs:
2727

2828
# Build + install + import the wheel here rather than in the regen workflow.
2929
# This job runs on any PR touching pyproject.toml/CHANGELOG.md — which every
30-
# regen PR does (version bump + seeded changelog) — so a packaging or import
31-
# regression surfaces as red CI on the PR instead of silently aborting the
32-
# regen before it can open one. (Step name kept stable to preserve the
30+
# regen PR does (it seeds a [Unreleased] changelog entry) — so a packaging or
31+
# import regression surfaces as red CI on the PR instead of silently aborting
32+
# the regen before it can open one. (Step name kept stable to preserve the
3333
# required-check / branch-protection wiring.)
3434
- name: Build, install, and import the wheel
3535
run: |

.github/workflows/regenerate.yml

Lines changed: 42 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -67,75 +67,64 @@ jobs:
6767
esac
6868
done
6969
70-
# pyproject.toml is hand-maintained (see .openapi-generator-ignore), so the
71-
# generator no longer stamps the version. Bump the patch version directly,
72-
# rewriting the same `^version = "X"` line scripts/release.sh treats as the
73-
# source of truth.
74-
- name: Bump package patch version in pyproject.toml
75-
id: pkg
76-
run: |
77-
version=$(python3 - <<'PY'
78-
import re, pathlib
79-
path = pathlib.Path("pyproject.toml")
80-
text = path.read_text()
81-
m = re.search(r'(?m)^version = "(\d+)\.(\d+)\.(\d+)"', text)
82-
if not m:
83-
raise SystemExit("could not find a semver version in pyproject.toml")
84-
new = f"{int(m[1])}.{int(m[2])}.{int(m[3]) + 1}"
85-
text, n = re.subn(r'(?m)^version = "[^"]+"', f'version = "{new}"', text, count=1)
86-
if n != 1:
87-
raise SystemExit("failed to rewrite version in pyproject.toml")
88-
path.write_text(text)
89-
print(new)
90-
PY
91-
)
92-
echo "version=$version" >> "$GITHUB_OUTPUT"
93-
94-
# check-release.yml gates merges on a `## [x.y.z]` CHANGELOG section
95-
# matching the bumped version, so without a seeded entry every regen PR
96-
# fails that check. Insert a stub (the spec-change title under ### Changed)
97-
# just above the most recent released section; the PR author refines the
98-
# wording before merge. Idempotent: skips if a section for this version
99-
# already exists.
100-
- name: Seed changelog entry
70+
# The version bump is intentionally NOT done here. A regen is just a set of
71+
# changes; which release they ship in (and the bump kind) is decided later
72+
# by scripts/release.sh prepare. Seed the regen notes under [Unreleased] so
73+
# they accumulate there until a release rolls them into a version. This
74+
# avoids minting a dated version section per regen that may never publish.
75+
- name: Seed changelog entry under [Unreleased]
10176
env:
102-
VERSION: ${{ steps.pkg.outputs.version }}
10377
TITLE: ${{ inputs.title }}
10478
run: |
105-
export CHANGELOG_DATE=$(date -u +%Y-%m-%d)
10679
python3 - <<'PY'
10780
import os, pathlib, re
108-
version = os.environ["VERSION"]
109-
date = os.environ["CHANGELOG_DATE"]
11081
title = (os.environ.get("TITLE") or "").strip() \
11182
or "Regenerate the client from the updated Hotdata OpenAPI spec"
83+
bullet = f"- {title}"
11284
path = pathlib.Path("CHANGELOG.md")
11385
text = path.read_text()
114-
if re.search(rf"^## \[{re.escape(version)}\]", text, re.M):
115-
print(f"CHANGELOG already has a [{version}] section; leaving it untouched.")
116-
raise SystemExit(0)
117-
unreleased = re.search(r"^## \[Unreleased\]", text, re.M)
118-
if not unreleased:
86+
87+
heading = re.search(r"^## \[Unreleased\][^\n]*\n", text, re.M)
88+
if not heading:
11989
raise SystemExit("CHANGELOG.md has no '## [Unreleased]' section to anchor the new entry")
120-
# Insert before the first released section after [Unreleased] (falling
121-
# back to end of file) so any pending entries under [Unreleased] stay
122-
# attributed to it rather than being absorbed by the new version.
123-
nxt = re.search(r"^## \[", text[unreleased.end():], re.M)
124-
insert_at = unreleased.end() + nxt.start() if nxt else len(text)
125-
entry = f"## [{version}] - {date}\n\n### Changed\n\n- {title}\n\n"
126-
text = text[:insert_at] + entry + text[insert_at:]
127-
path.write_text(text)
128-
print(f"Inserted CHANGELOG [{version}] section.")
90+
91+
# Scope edits to the [Unreleased] body (up to the next '## [' or EOF).
92+
start = heading.end()
93+
nxt = re.search(r"^## \[", text[start:], re.M)
94+
end = start + nxt.start() if nxt else len(text)
95+
body = text[start:end]
96+
97+
if any(line.strip() == bullet for line in body.splitlines()):
98+
print("CHANGELOG [Unreleased] already lists this entry; leaving it untouched.")
99+
raise SystemExit(0)
100+
101+
changed = re.search(r"^### Changed[ \t]*\n", body, re.M)
102+
if changed:
103+
# Prepend the bullet to the existing ### Changed list.
104+
i = changed.end()
105+
while i < len(body) and body[i] == "\n":
106+
i += 1
107+
body = body[:i] + bullet + "\n" + body[i:]
108+
else:
109+
# No ### Changed yet: open one right under the heading.
110+
body = "\n### Changed\n\n" + bullet + "\n\n" + body.lstrip("\n")
111+
112+
path.write_text(text[:start] + body + text[end:])
113+
print("Added regen entry under CHANGELOG [Unreleased].")
129114
PY
130115
116+
# No packageVersion: pyproject.toml is hand-maintained and the generator
117+
# doesn't stamp it, while __version__ and the SDK version string both read
118+
# from installed package metadata — so the generator's packageVersion never
119+
# reaches committed output.
131120
- name: Generate client
132121
run: |
133122
npx @openapitools/openapi-generator-cli generate \
134123
-i openapi.yaml \
135124
-g python \
136125
-o . \
137126
-t .openapi-generator-templates \
138-
--additional-properties=packageName=hotdata,projectName=hotdata,packageVersion=${{ steps.pkg.outputs.version }},gitUserId=hotdata-dev,gitRepoId=sdk-python \
127+
--additional-properties=packageName=hotdata,projectName=hotdata,gitUserId=hotdata-dev,gitRepoId=sdk-python \
139128
--skip-validate-spec
140129
141130
# pyproject.toml/requirements*.txt are hand-maintained, so the generator no
@@ -300,8 +289,9 @@ jobs:
300289
# on. The PR is the artifact we want, and breakage surfaces on it as red CI:
301290
# integration-tests.yml installs the package (`pip install -e .`) and runs
302291
# pytest, and check-release.yml builds + installs + imports the wheel on
303-
# every PR that bumps pyproject.toml/CHANGELOG.md (which every regen PR
304-
# does). Auto-merge is gated on those checks, so a broken regen can't merge.
292+
# every PR touching pyproject.toml/CHANGELOG.md (a regen seeds a [Unreleased]
293+
# changelog entry, so it always touches CHANGELOG.md). Auto-merge is gated on
294+
# those checks, so a broken regen can't merge.
305295

306296
- name: Check integration test scenario parity
307297
env:

.openapi-generator-ignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ setup.py
77
# at the package root (outside hotdata/api and hotdata/models) so the generator
88
# never emits or overwrites them, but they are listed here as the source of
99
# truth for "hand-maintained, don't touch": _auth.py (JWT exchange), arrow.py
10-
# (Arrow IPC result fetch), query.py (429 retry + truncation auto-follow, #688).
10+
# (Arrow IPC result fetch), query.py (429 retry + truncation auto-follow, #688),
11+
# _retry.py (pre-response connection-reset retry on all methods, #118).
1112
hotdata/_auth.py
1213
hotdata/arrow.py
1314
hotdata/query.py
15+
hotdata/_retry.py
1416

1517
# Hand-written test for the patched ApiClient.close()/context-manager behavior
1618
# (re-applied by scripts/patch_api_client_close.py). It lives in the generated

.openapi-generator-templates/configuration.mustache

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,13 @@ conf = {{packageName}}.Configuration(
433433
self.safe_chars_for_path_param = ''
434434
"""Safe chars for path_param
435435
"""
436+
# Default to a retry policy that transparently retries pre-response
437+
# connection resets (stale pooled keep-alive connections) on every
438+
# method, including POST (#118). Passing an explicit `retries` (int or
439+
# urllib3.Retry) overrides it entirely.
440+
if retries is None:
441+
from {{packageName}}._retry import default_retry
442+
retries = default_retry()
436443
self.retries = retries
437444
"""Retry configuration
438445
"""

CHANGELOG.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [0.4.2] - 2026-06-18
11-
1210
### Changed
1311

1412
- feat(indexes): add source_column field to index responses
1513

16-
## [0.4.1] - 2026-06-17
14+
## [0.4.1] - 2026-06-19
1715

1816
### Changed
1917

18+
- `Configuration` now defaults to a retry policy that transparently retries
19+
pre-response connection resets (stale pooled keep-alive connections, e.g.
20+
`ProtocolError('Connection aborted.', ConnectionResetError)`) on **every**
21+
method, including `POST`. Such a reset happens before the request reaches the
22+
server, so retrying on a fresh connection cannot double-execute. Read timeouts
23+
and status retries stay idempotent-only. Pass an explicit `retries` to
24+
override (#118).
2025
- chore: make api doc language end-user focused
2126

2227
## [0.4.0] - 2026-06-16

hotdata/_retry.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Transparent retry of pre-response connection resets (#118).
2+
3+
A pooled keep-alive connection can be reused after an intermediary (load
4+
balancer / reverse proxy) has already dropped it for exceeding its idle
5+
timeout. The client is not notified, so the next request that reuses that
6+
socket fails immediately with::
7+
8+
urllib3.exceptions.ProtocolError:
9+
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
10+
11+
urllib3 classifies *every* :class:`~urllib3.exceptions.ProtocolError` as a
12+
*read* error (see ``Retry._is_read_error``) — it conservatively assumes the
13+
server may have begun processing the request. Read retries are gated by
14+
``allowed_methods``, which does not include ``POST``, so a ``POST`` that hits a
15+
dead pooled connection is re-raised to the caller instead of being retried.
16+
17+
But a reset that happens *before any response bytes arrive* means the request
18+
never reached the server — the connection was already gone when urllib3 tried
19+
to send. That is safe to retry on **any** method, ``POST`` included, because the
20+
server did no work and a retry cannot double-execute.
21+
22+
:class:`ConnectionResetRetry` reclassifies exactly that case — a
23+
``ProtocolError`` whose underlying cause is a connection-level ``OSError`` — as
24+
a *connection* error. urllib3's ``Retry.increment`` handles connection errors
25+
before read errors and without the ``allowed_methods`` gate, so the reset is
26+
retried on a fresh connection regardless of method. Read **timeouts**
27+
(:class:`~urllib3.exceptions.ReadTimeoutError`) and status retries are left
28+
untouched and stay idempotent-only, so a ``POST`` that may have reached the
29+
server is never blindly replayed.
30+
"""
31+
32+
from __future__ import annotations
33+
34+
from urllib3.exceptions import ProtocolError, ProxyError
35+
from urllib3.util.retry import Retry
36+
37+
# Bounded attempts for the transparent connection-reset retry. Stale-pool resets
38+
# clear on the first fresh connection, so a small ceiling is plenty; it also
39+
# bounds the blast radius if a host is genuinely refusing connections.
40+
DEFAULT_TOTAL_RETRIES = 3
41+
42+
# A small exponential backoff between attempts. The first retry on a fresh
43+
# connection almost always succeeds, so the delay stays sub-second; it exists
44+
# only to avoid hammering a host that is briefly flapping.
45+
DEFAULT_BACKOFF_FACTOR = 0.1
46+
47+
48+
def _is_pre_response_connection_reset(error: BaseException) -> bool:
49+
"""True for a connection reset/abort that occurred before any response.
50+
51+
urllib3 wraps a low-level socket failure raised while sending the request as
52+
``ProtocolError("Connection aborted.", <cause>)``, where ``<cause>`` is the
53+
originating :class:`OSError` (``ConnectionResetError``,
54+
``ConnectionAbortedError``, ``BrokenPipeError``, …). Those builtin
55+
connection errors all subclass :class:`ConnectionError`, so the cause's type
56+
is a precise, message-independent signal that the failure was at the socket
57+
layer before a response existed — distinct from a read **timeout**, which
58+
urllib3 raises as a ``ReadTimeoutError`` (not a ``ProtocolError``) and which
59+
we deliberately leave method-gated.
60+
"""
61+
if isinstance(error, ProxyError):
62+
error = error.original_error
63+
if not isinstance(error, ProtocolError):
64+
return False
65+
cause = error.args[1] if len(error.args) > 1 else None
66+
return isinstance(cause, ConnectionError)
67+
68+
69+
class ConnectionResetRetry(Retry):
70+
"""A :class:`urllib3.util.retry.Retry` that retries pre-response connection
71+
resets on any HTTP method, while leaving read/status retries idempotent-only.
72+
73+
Behaves exactly like ``Retry`` except a connection reset/abort that happened
74+
before any response bytes were received (see
75+
:func:`_is_pre_response_connection_reset`) is treated as a *connection*
76+
error rather than a *read* error. urllib3's ``increment`` retries connection
77+
errors without consulting ``allowed_methods``, so the reset is retried on a
78+
fresh connection for ``POST`` too — safe because the server did no work.
79+
"""
80+
81+
def _is_connection_error(self, err: Exception) -> bool:
82+
if super()._is_connection_error(err):
83+
return True
84+
return _is_pre_response_connection_reset(err)
85+
86+
87+
def default_retry() -> ConnectionResetRetry:
88+
"""The SDK's default retry policy.
89+
90+
Matches urllib3's defaults (idempotent-only read/status retries, a bounded
91+
total, no retry on response status codes) and adds transparent retry of
92+
pre-response connection resets on every method. ``Configuration`` installs
93+
this when the caller does not supply their own ``retries``.
94+
"""
95+
return ConnectionResetRetry(
96+
total=DEFAULT_TOTAL_RETRIES,
97+
backoff_factor=DEFAULT_BACKOFF_FACTOR,
98+
# Don't retry on response status codes by default: a status code means
99+
# the request reached the server, and 429 admission-shedding already has
100+
# dedicated handling in hotdata.query. Connection-reset retry is purely a
101+
# transport-layer concern.
102+
status=0,
103+
raise_on_status=False,
104+
)
105+
106+
107+
__all__ = [
108+
"ConnectionResetRetry",
109+
"DEFAULT_BACKOFF_FACTOR",
110+
"DEFAULT_TOTAL_RETRIES",
111+
"default_retry",
112+
]

hotdata/api_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def __init__(
9191
self.default_headers[header_name] = header_value
9292
self.cookie = cookie
9393
# Set default User-Agent.
94-
self.user_agent = 'OpenAPI-Generator/0.4.2/python'
94+
self.user_agent = 'OpenAPI-Generator/0.4.1/python'
9595
self.client_side_validation = configuration.client_side_validation
9696

9797
def __enter__(self):

hotdata/configuration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,13 @@ def __init__(
321321
self.safe_chars_for_path_param = ''
322322
"""Safe chars for path_param
323323
"""
324+
# Default to a retry policy that transparently retries pre-response
325+
# connection resets (stale pooled keep-alive connections) on every
326+
# method, including POST (#118). Passing an explicit `retries` (int or
327+
# urllib3.Retry) overrides it entirely.
328+
if retries is None:
329+
from hotdata._retry import default_retry
330+
retries = default_retry()
324331
self.retries = retries
325332
"""Retry configuration
326333
"""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "hotdata"
3-
version = "0.4.2"
3+
version = "0.4.1"
44
description = "Hotdata API"
55
authors = [
66
{name = "Hotdata",email = "developers@hotdata.dev"},

0 commit comments

Comments
 (0)