Skip to content

Reuse the curl handle and grow the response buffer geometrically - #57

Merged
mason-sharp merged 3 commits into
mainfrom
fix/issue-28-curl-reuse
Aug 12, 2026
Merged

Reuse the curl handle and grow the response buffer geometrically#57
mason-sharp merged 3 commits into
mainfrom
fix/issue-28-curl-reuse

Conversation

@dpage

@dpage dpage commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #28.

The issue said "benchmark and, if warranted", so I benchmarked before changing anything, against a local stub HTTP server that counts TCP connections as well as requests.

Measured

connections requests 200 calls
before 200 200 274 ms
after 1 200 189 ms

One connection per request became one connection for all of them, and wall clock fell 31% over loopback, where establishing a connection is about as cheap as it will ever be. Against a real provider each avoided connection also avoids a TLS handshake, so the saving is one to two further round trips per batch to a host that is rarely nearby. That part is arithmetic from the measured 200-to-1, not a separate claim.

The change

provider_do_curl_request() built a fresh handle per request, and a fresh handle knows of no connection. It now keeps one for the life of the backend. curl_easy_reset() clears the options without disturbing the connection pool, which is the load-bearing assumption of the whole thing, so I verified it rather than taking it from the documentation: the connection count in the table is that verification.

The handle is deliberately never cleaned up. It lives as long as the backend, and an idle keep-alive connection is closed by the server at its own timeout, so nothing is held open indefinitely.

Separately, ResponseBuffer grew to exactly the size wanted on every write callback, meaning a repalloc per chunk curl delivers, each liable to copy everything received so far. It now tracks capacity and doubles, so a response of any size costs a handful of reallocations rather than hundreds.

A warning for whoever benchmarks this next

My first run showed the change making things twenty-six times slower, at 8.4 seconds against 320 ms. That was the measuring instrument, not the code. Python's http.server does not set TCP_NODELAY, so once keep-alive put all 200 requests on a single socket, each one paid the 40 ms delayed-ACK stall; 200 × 42 ms is precisely the 8.4 seconds observed. Setting disable_nagle_algorithm on the stub removed it completely and produced the numbers above.

I mention it because the failure is specific to reusing a connection, so anyone re-running this comparison with a naive stub will hit it and could easily conclude the change is a regression. Real HTTP servers set TCP_NODELAY.

Test plan

  • 19 regression tests and all TAP suites green on PostgreSQL 18.4, clean build
  • Connection reuse verified by counting accepted connections server-side, at both 20 and 200 calls
  • Both builds benchmarked through the identical harness, with the stub's own counters confirming all 200 requests actually reached it

One caveat on the wall-clock figures: they come from a single run each against a local Python stub, so treat 31% as the shape of the result rather than a precise number. The connection count is the exact and reproducible part.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a04e0d1a-dc75-40df-a350-476af68a6f9c

📥 Commits

Reviewing files that changed from the base of the PR and between 7b7ce34 and 5b3428c.

📒 Files selected for processing (2)
  • src/provider_common.c
  • src/provider_common.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/provider_common.c

📝 Walkthrough

Walkthrough

The provider HTTP path now caches and resets a curl handle across requests. Response buffers start with a 32 KiB capacity and grow geometrically with non-throwing allocation. ResponseBuffer records capacity and allocation failure. Curl transfer errors now distinguish response-buffer allocation failures from other transfer failures.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes both primary changes: curl-handle reuse and geometric response-buffer growth.
Description check ✅ Passed The description directly explains the implementation, benchmarks, performance impact, and test coverage for the requested HTTP improvements.
Linked Issues check ✅ Passed The changes satisfy issue #28 by reusing curl handles, enabling connection reuse, and adding capacity-based response-buffer growth with failure diagnostics.
Out of Scope Changes check ✅ Passed The changes remain within issue #28 and contain no unrelated code modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-28-curl-reuse

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Aug 11, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 9 complexity · 0 duplication

Metric Results
Complexity 9
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

dpage and others added 2 commits August 11, 2026 19:02
Benchmarked before changing anything, as the issue asked.

provider_do_curl_request() built a fresh handle for every request, and a
fresh handle knows of no connection, so each batch opened its own. Measured
against a local stub: 200 requests made 200 TCP connections. With the handle
kept for the life of the backend that becomes 200 requests over 1 connection,
and 274ms falls to 189ms over loopback, where establishing a connection is
about as cheap as it can be. Against a real provider each avoided connection
also avoids a TLS handshake, so the saving there is one to two extra round
trips per batch to a host that is rarely nearby.

curl_easy_reset() clears the options without disturbing the connection pool,
which is what lets reuse survive between requests. That is the load-bearing
assumption of the whole change, so it was verified rather than taken from the
documentation: the connection count above is the evidence. The handle is
deliberately never cleaned up, since it lives as long as the backend and an
idle keep-alive connection is closed by the server at its own timeout.

The response buffer grew to exactly the size wanted on every write callback,
which is a repalloc for every chunk curl delivers, each liable to copy
everything received so far. It now tracks capacity and doubles, so a response
of any size costs a handful of reallocations rather than hundreds.

A note for whoever benchmarks this next. A first attempt showed the change
making things twenty-six times slower, which was the measuring instrument
rather than the code: Python's http.server does not set TCP_NODELAY, so
once keep-alive put every request on one socket each one paid the 40ms
delayed-ACK stall, 200 of which is the 8.4 seconds observed. Setting
disable_nagle_algorithm on the stub removed it entirely.

Closes #28
repalloc raises on failure and the callback runs inside
curl_easy_perform(), so the longjmp abandoned the handle mid-transfer.
With the handle now cached for the life of the backend, every later
request reused the wreckage: measured against a stub, a response over
MaxAllocSize failed and then every subsequent request in that backend
failed with "Failed initialization".

The callback now checks the size itself, uses MCXT_ALLOC_NO_OOM, and
returns short so curl reports CURLE_WRITE_ERROR through the existing
error path. Doubling that overshoots MaxAllocSize retries at the exact
size, restoring the ~1GB ceiling that geometric growth had halved.
@mason-sharp
mason-sharp force-pushed the fix/issue-28-curl-reuse branch from 21f3185 to 7b7ce34 Compare August 12, 2026 02:02

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/provider_common.c`:
- Around line 486-492: Update the alloc_failed branch in the response error
handling to use a neutral message such as “could not grow response buffer,”
covering both size validation and allocation failures; do not report these cases
as the response being too large. Preserve the existing curl_easy_perform() error
path for other failures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f61cf37-d702-40b3-89b5-943c9452483e

📥 Commits

Reviewing files that changed from the base of the PR and between abc775e and 7b7ce34.

📒 Files selected for processing (2)
  • src/provider_common.c
  • src/provider_common.h

Comment thread src/provider_common.c Outdated
A single alloc_failed flag reported an allocation refused for want of
memory as a response too large to buffer, which points the operator at
their provider when the response size may be perfectly ordinary and the
server is simply short of memory. Record which of the two happened and
say so.

Raised by CodeRabbit on #61.
@mason-sharp
mason-sharp merged commit 45eb940 into main Aug 12, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provider HTTP performance: reuse curl connections and pre-allocate response buffers

2 participants