I believe that what you can do in one art form, you can do in another.
— Nicolas Cage, Interview Magazine, conversation with Marilyn Manson (excerpt)
Placing a phone call through Twilio is one HTTP request. What goes into that request, and what the answer means, are Twilio’s rules. Getting the request out of the machine is the host’s job. That split is the whole design.
This is part 2 of 3. Part 1 set out the method, the four hosts and the six steps, G1 to G6, that this part follows. Part 3 applies them to a speech detector.
Here is the status, stated once. The Twilio client lives in the shared transport crate, and a lint gate checks that crate for the native, Cloudflare and browser feature sets. Two native programs dial with it today: the command service and our CLI. No Worker dials a carrier yet, so the Worker diagram below is a sketch.
The carrier’s rules live in one client. Only the way a request leaves the machine changes per host.
The shared client¶
[G1] Requirement: the same DialRequest must produce the same Twilio request, and the same DialReceipt or typed failure, whichever adapter sends it.
[G2] Contract: DialRequest → DialReceipt. Failures are TransportError<H::Failure>, where H::Failure is the adapter’s own error type. HTTP is the effect that needs an interface.
[G3] Core: TwilioClient<H> in the transport crate owns the URL, authentication, form fields and response decoding, in about 110 lines. This is the real dial method:
async fn initiate_call(
&self,
request: &DialRequest,
) -> TransportResult<DialReceipt, H::Failure> {
let url = format!(
"{}/2010-04-01/Accounts/{}/Calls.json",
self.api_base, self.account_sid
);
let form = call_form(
request.to.as_str(),
self.phone_number.as_str(),
&self.application_sid,
);
let response = self
.http
.post(&url)
.basic_auth(&self.account_sid, Some(&self.auth_token))
.form(&form)
.send()
.await
.map_err(|e| {
TransportError::request(TransportVendor::Twilio, TransportOperation::Dial, e)
})?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(TransportError::Http {
transport: TransportVendor::Twilio,
operation: TransportOperation::Dial,
status,
body,
});
}
let result: TwilioCallResponse = response.json().await.map_err(|e| {
TransportError::response_decode(TransportVendor::Twilio, TransportOperation::Dial, e)
})?;
Ok(DialReceipt::Twilio {
call_sid: result.sid,
})
}The URL, the form and the decoding are Twilio’s rules. Only send crosses to the host.
A fix to a Twilio rule lands in this one method, and every host gets it on its next build. Keep the result honest too: a call SID means Twilio accepted the request. It does not mean that the person answered.
post, basic_auth and form come from the shared platform crate. send hands the finished request to HttpClientExt::execute, the only path to an adapter:
pub trait HttpClientExt: HttpClient {
fn execute(
&self,
request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse<Self::Failure>, HttpError<Self::Failure>>> + HttpSend
{
let policy = request_policy(&request, self.default_policy());
let mode = response_body_mode(&request);
self.send(request, policy, mode)
}
}
impl<T: HttpClient + ?Sized> HttpClientExt for T {}This is the fix for the response-limit bug from part 1. request_policy resolves the limit in shared code, and the blanket impl means that no adapter can skip it.
The response type keeps the body inside a Result: http::Response<Result<Vec<u8>, HttpError<E>>>. Status and headers arrive before the body. If the body fails halfway, the client still knows what Twilio said. The retry rules below depend on that.
[G4] Host connection: each adapter implements two methods. default_policy returns the policy the adapter was built with. send receives the resolved policy and body mode, and performs the I/O. The two adapters are 127 and 188 lines, against 372 lines of shared HTTP contract.
In TwilioClient<H>, H is the adapter type, and the application picks it:
| Host | Adapter type |
|---|---|
| Native | platform::http::native::NativeClient |
| Cloudflare Worker | platform::http::cloudflare::CloudflareHttpClient |
Select and use the adapter¶
These are the command service’s dependencies:
[dependencies]
transport = { workspace = true, features = ["telephony-control", "webhooks", "native"] }
platform = { workspace = true, features = ["native"] }telephony-control includes the carrier operations. The native feature of transport forwards to platform/native, which compiles the reqwest adapter. The service builds the adapter itself at startup:
let transport_http =
platform::http::native::NativeClient::new(transport::TRANSPORT_HTTP_POLICY)
.map_err(StartupError::TransportHttp)?;TRANSPORT_HTTP_POLICY allows 15 seconds and 16 KiB. The adapter then goes into TwilioClient::with_http(&settings, http, api_base), and Rust infers H from it. A Worker would change one thing: it would build a CloudflareHttpClient and enable the cloudflare features instead.
Four adapters, then two¶
When we found the response-limit bug, platform had four HTTP adapters: native, Cloudflare, browser fetch and a Wasmtime guest. We have since deleted two of them.
The browser adapter had no caller in our repo or in customer code. That was the right outcome, because a browser should not dial at all. The request carries long-lived carrier credentials, and any browser user can read them. Browser requests also face CORS rules. A browser build can still share validation, such as phone number and callback URL checks, and hand the call to a server.
The guest adapter had one user: an example that only a manual make target ran. Real plugins call the host’s HTTP builtin directly.
An adapter that nothing calls still has a cost. Every change to the shared contract must compile and pass the lint gate in it, and every review must read it.
Write an adapter when a host needs one. Delete it when nothing calls it.
What changes on each host¶
The same client compiles for every host feature set, and only the adapter changes. Here is what each one does.
Native service. NativeClient owns a reqwest client with its connection pool, sets the timeout, follows no redirects and makes no retries. Its futures must be Send; the next section explains why.
Cloudflare Worker. CloudflareHttpClient uses the worker crate’s Fetch. A Worker that dials would read the carrier credentials from Cloudflare Secrets.
Sketch. The Worker would send the carrier request from a V8 isolate through platform fetch.
Wasmtime host. The guest adapter is gone, but the host still sets the rules for every Component that makes a request. A plugin calls the host’s HTTP builtin, and the host checks the request: https only, public addresses, no redirects or proxy, and default ceilings of 10 seconds and 16 KiB. A timeout above the ceiling fails with InvalidRequest. A larger response limit is clamped to the ceiling. The guest asks, and the host decides whether the request leaves.
One trait, two thread rules¶
This section covers only the difference between targets. The Rust Book explains what Send and Sync protect.
A shared trait with async fn compiles on both targets, but rustc warns: use of async fn in public traits is discouraged as auto trait bounds cannot be specified. The missing bound is Send, and the two targets want different answers.
- Native:
tokio::spawncan resume a task on another thread, so its future must beSend. If the future borrows the HTTP client across.await, the client must also beSync. - wasm32: everything runs on one thread. JavaScript handles such as
JsValueare neitherSendnorSync, so those bounds would reject any client that holds one.
platform uses marker traits that switch the bounds on for native and off for Wasm: HttpHost for the client and HttpSend for the future. (A third, HttpSendSync, bounds the error type.) Blanket impls give them to every type that qualifies, so adapters never implement them by hand. This is the real code:
#[cfg(not(target_arch = "wasm32"))]
pub trait HttpHost: Send + Sync {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + Sync> HttpHost for T {}
#[cfg(target_arch = "wasm32")]
pub trait HttpHost {}
#[cfg(target_arch = "wasm32")]
impl<T> HttpHost for T {}
#[cfg(not(target_arch = "wasm32"))]
pub trait HttpSend: Send {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + ?Sized> HttpSend for T {}
#[cfg(target_arch = "wasm32")]
pub trait HttpSend {}
#[cfg(target_arch = "wasm32")]
impl<T: ?Sized> HttpSend for T {}
pub trait HttpClient: HttpHost { // bound on the client
type Failure: HttpFailure;
fn default_policy(&self) -> HttpPolicy;
fn send(
&self,
request: HttpRequest,
policy: HttpPolicy,
mode: ResponseBodyMode,
) -> impl Future<Output = Result<HttpResponse<Self::Failure>, HttpError<Self::Failure>>> + HttpSend;
} // bound on the futuresend keeps one signature, and code that calls it is identical on both targets. A future that is not Send can still run on Tokio through spawn_local. It cannot go to tokio::spawn.
[G5] Build transport¶
[T1] The transport crate is the same for every host; the host feature selects the adapter. Run the commands from the repository root.
# [T2a] The native command service, which dials through NativeClient
cargo build --locked -p command --bin command \
--target x86_64-unknown-linux-gnu --release
# [T2b] Lint the transport crate for one Wasm host feature set
cargo clippy --locked -p transport --no-default-features \
--features cloudflare,telephony-control,webhooks,media,widget \
--target wasm32-unknown-unknown --lib -- -D warningsThere is no transport Worker to build yet, so a lint gate is the build step for the other hosts. It runs that clippy command for each of native, cloudflare and wasm, on wasm32-unknown-unknown for the two Wasm feature sets. It then reads cargo tree and fails if a Wasm build pulls in reqwest or Tokio networking. It also checks transport-contracts and transport across their feature sets on both targets, and fails if the portable contracts depend on an implementation.
[G6] Verify: two tests check the shared path with a fake client that records what it receives: execute_passes_request_policy_override_to_adapter and execute_falls_back_to_adapter_default_policy. The native adapter also has tests against a real local server. The Cloudflare adapter has a unit test for its method mapping, but no run-time fetch test yet, so the gate proves only that it compiles and passes the lints. The next test to add is the one that the response-limit bug calls for: the same fixture, through every real adapter.
Do not retry on a guess¶
One failure needs special care on a phone platform. The carrier accepts the call, and then the network drops the response. The code sees an error and retries, and the same person gets two calls. To avoid that, keep three states apart:
- No response arrived. The request may or may not have reached the carrier.
- A response arrived, but its body failed. The carrier answered. With a success status, a call probably exists, and only its identifier is lost.
- The connection never opened. The request never left, so a retry is safe.
The last state depends on the host. The native adapter can tell a failed connection from a timeout, because reqwest reports both. The Cloudflare adapter reports the cause as unknown, unless its own deadline fired, which it reports as a timeout.
An adapter must not claim more than its host knows.
The application can then check with the provider, instead of retrying on a guess.
Next: share the rules, not the model¶
An HTTP call is the easy case. It has one request and one answer. Part 3 shares a speech detector, whose model runs on a different engine on each host.