Skip to content

feat: add async API for non-blocking HTTP requests#173

Open
puzza007 wants to merge 5 commits into
masterfrom
feat/async-api
Open

feat: add async API for non-blocking HTTP requests#173
puzza007 wants to merge 5 commits into
masterfrom
feat/async-api

Conversation

@puzza007
Copy link
Copy Markdown
Owner

Closes #115

Adds async_get/2,3, async_post/2,3, etc. and async_req/2 that return {ok, Ref} immediately instead of blocking. Responses arrive as flat messages: {katipo_response, Ref, ResponseMap} for success, {katipo_error, Ref, ErrorMap} for errors.

Includes await/1,2 helper for the common "fire N requests, collect results" pattern. Supports reply_to option to direct responses to a different process.

No C port changes — purely Erlang-side, using wpool:cast with synthetic internal refs for port correlation. API follows the pattern from the issue discussion (single complete response, not streaming chunks). Inspired by buoy's approach.

await/2 distinguishes its own timeout (await_timeout) from request-level timeouts (operation_timedout), and flushes any late-arriving response from the mailbox on timeout.

Add async_get, async_post, async_put, async_head, async_options,
async_patch, async_delete, and async_req functions that return
{ok, Ref} immediately and deliver responses as messages.

Success: {katipo_response, Ref, ResponseMap}
Error:   {katipo_error, Ref, ErrorMap}

Also adds await/1,2 for convenient synchronous collection of async
results, with mailbox cleanup on timeout and distinct await_timeout
error code.

Supports reply_to option to direct responses to a different process.
No C port changes required — uses wpool:cast with synthetic internal
refs for port correlation.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a non-blocking (message-based) async request API to katipo, plus a small await/1,2 helper and corresponding Common Test coverage to support the “fire N requests, collect results” pattern from #115.

Changes:

  • Introduces async_req/2 and async_{get,post,put,head,options,patch,delete}/2,3 returning {ok, Ref} and delivering results via {katipo_response|katipo_error, Ref, Map}.
  • Adds await/1,2 to block on an async reference (with its own timeout semantics).
  • Extends katipo_SUITE with a new async test group covering core success/error/timeout/reply_to behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/katipo.erl Adds async public API, async worker plumbing, and await/1,2.
test/katipo_SUITE.erl Adds an async CT group and test cases validating the new async interface.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/katipo.erl
Comment on lines +58 to +74
-export([async_req/2]).
-export([async_get/2]).
-export([async_get/3]).
-export([async_post/2]).
-export([async_post/3]).
-export([async_put/2]).
-export([async_put/3]).
-export([async_head/2]).
-export([async_head/3]).
-export([async_options/2]).
-export([async_options/3]).
-export([async_patch/2]).
-export([async_patch/3]).
-export([async_delete/2]).
-export([async_delete/3]).
-export([await/1]).
-export([await/2]).
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The module docs still state “All request functions return t:response/0”, but this file now exports async request functions that return {ok, Ref} and deliver results via messages. Please update the moduledoc to describe the async API and clarify that reply_to is only meaningful for async calls.

Copilot uses AI. Check for mistakes.
Comment thread src/katipo.erl Outdated
Comment thread src/katipo.erl
Comment on lines +891 to +897
{ok, {Tref, {async, ReplyTo, UserRef}}} ->
%% Async path — flatten into separate ok/error messages
{ResponseMap, _Metrics} = Response,
_ = erlang:cancel_timer(Tref),
case Result of
ok -> ReplyTo ! {katipo_response, UserRef, ResponseMap};
error -> ReplyTo ! {katipo_error, UserRef, ResponseMap}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

Async requests bypass do_req_with_span/2, and in the async response path you discard Metrics and never call katipo_metrics:notify/4 (nor create spans). This means async usage won’t emit the OTel metrics/tracing that sync requests do. Consider adding metrics notification (and documenting/handling tracing expectations) for the async path.

Copilot uses AI. Check for mistakes.
Comment thread src/katipo.erl
after 0 ->
ok
end,
{error, #{code => await_timeout, message => <<>>}}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

await/2 returns {error, #{code => await_timeout, message => <<>>}} on timeout; the empty message makes the error harder to debug/handle consistently. Populate a non-empty message (optionally including the timeout value) to match the other error maps.

Suggested change
{error, #{code => await_timeout, message => <<>>}}
Message = list_to_binary(
io_lib:format("await timeout after ~p", [Timeout])),
{error, #{code => await_timeout, message => Message}}

Copilot uses AI. Check for mistakes.
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 20, 2026

Codecov Report

❌ Patch coverage is 81.81818% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.08%. Comparing base (6529e77) to head (1b1222a).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/katipo.erl 81.81% 10 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #173      +/-   ##
==========================================
+ Coverage   72.78%   73.08%   +0.30%     
==========================================
  Files           6        6              
  Lines        1062     1111      +49     
==========================================
+ Hits          773      812      +39     
- Misses        289      299      +10     
Flag Coverage Δ
c 73.08% <81.81%> (+0.30%) ⬆️
erlang 73.08% <81.81%> (+0.30%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Replace google.com with 192.0.2.1 (TEST-NET-1, RFC 5737) in timeout
tests. The previous approach was flaky in low-latency environments
where TCP handshakes to google.com could complete in under 1ms.
Codecov patch/project checks were failing the build when coverage
changed. Set both to informational so they report but don't block.
- Update moduledoc to document async API and note OTel limitation
- Validate reply_to is a pid before dispatching async request
Add tests for async_put, async_head, async_options, async_patch,
async_delete, invalid reply_to, and missing URL. Brings katipo.erl
coverage from 78% to 81%.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/katipo_SUITE.erl
Comment on lines 552 to +555
connecttimeout_ms(_) ->
%% Use TEST-NET-1 (RFC 5737) — guaranteed non-routable, so connect always times out
{error, #{code := operation_timedout}} =
katipo:get(?POOL, <<"http://google.com">>, #{connecttimeout_ms => 1}).
katipo:get(?POOL, <<"http://192.0.2.1">>, #{connecttimeout_ms => 1}).
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

Using 192.0.2.1 as a “guaranteed timeout” target isn’t fully reliable across environments: some CI/network setups may return immediate errors like network_unreachable/couldnt_connect instead of timing out, which would make this test flaky. Consider loosening the assertion to accept the expected alternative error codes, or use a more deterministic local setup for forcing connect timeouts.

Copilot uses AI. Check for mistakes.
Comment thread test/katipo_SUITE.erl
Comment on lines +1276 to +1281
async_timeout(_Config) ->
{ok, Ref} = katipo:async_get(?POOL, <<"http://192.0.2.1">>,
#{connecttimeout_ms => 1}),
receive
{katipo_error, Ref, #{code := operation_timedout}} -> ok
after 10000 ->
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

This test relies on http://192.0.2.1 producing operation_timedout, but depending on routing it can fail fast with a different curl error (e.g. couldnt_connect) instead of timing out. To avoid flaky failures, consider making the assertion tolerant of those cases or use a deterministic target to exercise the timeout path.

Copilot uses AI. Check for mistakes.
Comment thread test/katipo_SUITE.erl
async_await_own_timeout(_Config) ->
%% await/2 timeout fires before the response arrives
{ok, Ref} = katipo:async_get(?POOL, <<"http://192.0.2.1">>,
#{timeout_ms => 30000, connecttimeout_ms => 30000}),
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

async_await_own_timeout/1 starts an async request with 30s connect/request timeouts but returns after await(Ref, 1). The underlying request will keep a pool worker busy until it times out, which can slow down or destabilize other [parallel] tests (pool size is small). Consider using much smaller request-level timeouts (still > await timeout) or running this case non-parallel/isolated.

Suggested change
#{timeout_ms => 30000, connecttimeout_ms => 30000}),
#{timeout_ms => 100, connecttimeout_ms => 100}),

Copilot uses AI. Check for mistakes.
Comment thread src/katipo.erl
Comment on lines +768 to +776
%% Flush any late-arriving response for this Ref
receive
{katipo_response, Ref, _} -> ok;
{katipo_error, Ref, _} -> ok
after 0 ->
ok
end,
{error, #{code => await_timeout, message => <<>>}}
end.
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

await/2 returns {error, #{code => await_timeout}} after the timeout, but the current “flush” only removes a response that has already arrived by the time the timeout fires. If the async request completes later (e.g., long timeout_ms), the {katipo_response|katipo_error, Ref, ...} message will still be delivered to the caller and remain in the mailbox. Either clarify this behavior in the docs/comment, or add tracking/cancellation so late responses for timed-out awaits are reliably suppressed/consumed.

Copilot uses AI. Check for mistakes.
Comment thread src/katipo.erl
Comment on lines +1227 to +1228
opt(reply_to, Pid, {Req, Errors}) when is_pid(Pid) ->
{Req, Errors};
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

opt(reply_to, Pid, ...) currently accepts reply_to in option maps but then ignores it (doesn’t store it in #req{} or affect sync req/2). This makes reply_to silently no-op for synchronous APIs (and check_opts/1). Consider rejecting reply_to for sync request processing (treat as bad_opts) or explicitly document that it’s only meaningful for async_*/async_req and ignored otherwise.

Copilot uses AI. Check for mistakes.
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.

Support for async interface

2 participants