Skip to content

Sending requests out

Every request your app sends to the internet goes through our outbound proxy. That is where secrets are added, and where you can hold an app to a list of hosts. Both are on every plan.

A request that fails on the way still gets an answer, so check the status before reading the body. See When a request fails.

Your app gets one of these answers rather than an error from fetch. Workers and containers get the same, except for a body that breaks, as below.

  • The name doesn’t resolve. Cloudflare answers 530, with the body error code: 1016.
  • Nothing answers at the host’s port. Cloudflare answers with one of its own 5xx codes, such as 522 when the connection times out. Cloudflare’s 5xx errors lists them.
  • We can’t send it. We answer 502, with Outbound requests can't be sent right now, so nothing was sent. Try again in a moment.
  • It fails on its way, as when a connection drops. We answer 502, with The request failed on its way, and the host may have taken it. Check before you send it again. The host may have had all of it, so look before you send it again, or send it with an idempotency key if the host takes one.
  • The request body breaks partway, and we can tell. We cut the request off, so the host never takes the part that came for the whole body. The host may still have seen that part. Your app gets a 502 with The request's body didn't arrive whole, so the request was cut off partway and the host may have seen part of it., or the error its own code stopped with.

Cloudflare’s answers come to your app as Cloudflare gave them. They aren’t ours, so they don’t look like our 502.

We never send part of a body as if it were all of it, when we can tell it broke. What we can tell depends on how your app sends it.

In a Worker:

  • Your app gives up on the request before its body is sent, by aborting it with an AbortController. We cut it off, and your fetch throws the abort, as it always does.
  • The body says its length and doesn’t match it: a stream sent with a Content-Length header, or a FixedLengthStream, that comes up short of that length or runs past it. We cut it off, and your app gets the 502 for a request cut off partway. We send a body that matches with the length it says. To avoid it, set Content-Length only to the body’s exact size in bytes, which for text is new TextEncoder().encode(text).byteLength, not its length in characters. Or leave the header off and send a string, an ArrayBuffer or a Blob, which the runtime sends with its own length.
  • A string, an ArrayBuffer, a Blob or FormData is whole before it’s sent, so it can’t break.
  • A stream your code made fails, with no length said: a ReadableStream, the readable side of a TransformStream, or one piped or teed from those. The Workers runtime sends what was read as if it were the whole body, as it does for any Worker on Cloudflare, so nothing after it can tell. The host takes the part that came for the whole body, and your app gets the host’s answer. To be sure it can’t happen, set Content-Length when you know the length, or read the stream whole first with await new Response(stream).arrayBuffer() and send that: a stream that fails then throws in your code, and nothing is sent.

This is the same for a Worker deployed with any version of forculus.

In a container, an HTTP client like Node’s fetch closes the connection when its request body fails, and so does a process that dies partway. We cut the request off, and your code gets the client’s own error, not an answer. A container app gets this the next time it’s deployed. Until then, a body it sends with no length can reach the host as if it were whole, and one with a Content-Length is cut off.

A request with a company AI key is read whole before it’s sent, to price it. One that doesn’t arrive whole isn’t priced or sent.

Our proxy’s own log never holds what a request carried, or the words of the error its body failed with. Your app’s log holds what your code writes and the exceptions it doesn’t catch, its own error text included.

An app’s Activity tab has Sends requests to: the hosts the app sent requests to in the last 30 days, busiest first, with refused requests in red: ones its list didn’t allow, ones to an AI provider the company key doesn’t cover, and ones to an IP address, which Cloudflare refuses. The app’s editors and owners can read the same with forculus egress usage acme/payroll, or through the API at GET /v1/workspaces/<workspace>/apps/<app>/egress/usage, along with the workspace’s owners and admins. Owners and admins also see each app’s busiest hosts in the workspace’s list of apps and its CSV, and the workspace export carries each app’s list and AI grants and which AI keys the workspace has, never a key.

Counts are kept by the hour and can take a few minutes to show. A request is counted as sent once the host has answered, so a name that doesn’t resolve isn’t counted, and neither is a request we cut off. One sent from a workspace’s fixed addresses that fails on its way once it has left them is counted as sent, since it may have reached the host. Cloudflare’s other 5xx answers, like 522, are counted as sent, since a host behind Cloudflare can give them too. A request to an IP address that Cloudflare refuses is counted as refused. An app that sends requests to more than 2,000 different hosts in an hour has the rest counted together as other hosts, with how many hosts went into that line, and so does a name no real host could have. Counts are kept for 90 days. We count only the host: never the path, the headers or the body.

By default an app can send requests to any host except our own, and except a private address like 10.0.0.1, 127.0.0.1 or 169.254.169.254, which no public host has. A request to one is refused with 403 and Refused: apps can't send requests to a private address., list or no list, so no app can reach another app’s container, or anything else on our network, by its address. To allow only the hosts you name, add them to the app’s list and turn the list on:

Terminal window
forculus egress allow acme/payroll api.stripe.com
forculus egress allow acme/payroll "*.googleapis.com"
forculus egress on acme/payroll
acme/payroll can send requests only to these hosts:
*.googleapis.com
api.stripe.com

A request to any other host is refused with a 403 that says Refused: example.org isn't on this app's list of hosts it can send requests to., and nothing is sent. To an AI provider’s host it comes in that provider’s error shape, so its SDK shows it. The refusal carries an x-forculus-egress-list header with the version of the list that refused it, which goes up with every change.

forculus egress list acme/payroll shows the list and whether it is on. forculus egress deny acme/payroll api.stripe.com takes a host off, and forculus egress off acme/payroll lets the app reach any host again. Changes apply within 10 seconds.

In the dashboard, the list is under the app’s Settings, in Sending requests out.

Only the workspace’s owners and admins can let an app reach more: add a host, or turn the list off. The app’s editors and owners can hold it in further: turn the list on, or take a host off. Every change is in the workspace’s audit log as egress.allow, egress.deny, egress.restrict or egress.unrestrict.

  • An exact host, like api.stripe.com. It matches that host only, not x.api.stripe.com.
  • *. and a domain, like *.stripe.com. It matches every host under stripe.com, at any depth, but not stripe.com itself. Add both if you need both. A suffix anyone can register a name under, like *.github.io, *.workers.dev, *.amazonaws.com or *.co.uk, can’t be listed: it would let the app reach hosts anybody can make. We refuse the ones on the Public Suffix List, and some common ones that aren’t, so still check that a pattern covers only hosts you trust.
  • Not one of ours, like *.forculuslabs-apps.tech: an app’s requests to our own hosts are always refused, so listing one would only mislead.
  • Not a name nobody can register, like foo.internal or printer.local: no request could reach it.

Hosts are matched without regard to case, a trailing dot or the port: listing api.stripe.com allows it on any port. An international name can be given in either form, bücher.de or xn--bcher-kva.de, and is kept in the second. An IP address can’t be listed, so with the list on, a request to an address is always refused. A list holds up to 200 hosts. A service on your private network that an owner or admin has granted the app is reached whatever its list says, by name or address: the grant is the allow. So the list doesn’t show those services. The Private network page and forculus network list show which apps each one is granted to.

With the list on and nothing on it, the app can’t send requests anywhere. The key set your app verifies sign-ins with is the one exception: it is always reachable, so the SDKs keep working.

The proxy checks the host in the URL it is about to fetch, after the URL is parsed. So:

  • Credentials in a URL don’t count: https://api.stripe.com@evil.example/ goes to evil.example, and is refused.
  • A Host header your app sets is dropped. The URL decides where a request goes.
  • The proxy never follows a redirect. Your app gets the redirect back, and when it follows one, that request is checked like any other. A listed host can’t send your app on to one that isn’t listed.
  • CONNECT is refused, so nothing can open a tunnel to a host that isn’t listed.

The same holds for Workers apps and container apps, with one difference: a container app can’t send an HTTPS request to an IP address at all. The connection is reset during the TLS handshake, before anything is sent, because a connection to an address carries no host name for our proxy to answer as. Node’s fetch says ECONNRESET, and curl exits with (35) Recv failure: Connection reset by peer. Use the host’s name. Over http, a request to a public address passes our checks, and then Cloudflare refuses it with 403 and the body error code: 1003, since Workers can’t send a request to an address.

The list says which hosts your app’s requests go to, not what those hosts do with them. A listed API that fetches a URL you give it, sends a webhook or a message, or stores what you send, can pass data on to places the list doesn’t name. List the hosts you need and no more, and know what each of them can do on your behalf.