Hey,
I took your simple app as a template to do more testing. I think I now have a good understanding of where the issue may be.
HTTP response termination in streaming mode
There are two levels of termination, one is the HTTP protocol level, and the other is the TCP connection level. In my previous example, the server sets the response close header, and the client is reacting to that header when it detects that the response is finished at the HTTP protocol level. The client then stops the ongoing upload and terminates correctly. The end of the streaming response is signaled by a zero block in the chunked transfer encoding, and can be seen when starting curl with --raw
. This works well locally.
The issue behind the fly.io proxy is, that the final zero chunk does not arrive right after the app has sent it. This makes the client believe that the upload is ongoing. This behavior then triggers handling of the connection termination at the TCP level, which should not be the case.
Interestingly, in this setup, if curl receives the final zero chunk in the streaming response, but doesn’t receive the response close header, it keeps hanging. This may be a curl bug.
TCP level connection termination
TCP level handling is a little more complicated, because there are several connections involved. If the client tries to send more data, and if the TCP connection is closed by the app, the fly.io proxy realizes this and terminates the connection. This only works, if the client sends new data, but if it keeps the connection up without sending new data, the client just hangs.
Previous TCP-level behavior
The issue I reported was tested with fly proxy e660f5c79 (2025-04-22)
. HAProxy has an option called httpclose
which always adds the close header to the response and is also supposed to terminate the TCP connection. However, the upload was still continuing endlessly in the described setup. Now, with fly proxy f22fdaf3f (2025-04-30)
, I cannot reproduce this behavior, but with the reproducible example below, the fly proxy may still consume > 60 MiB of data.
Reproducible example
App
I’ve taken your Python example, expanded it a little, and deployed it as a test app on fly.io.
server.py
import asyncio
import os
from aiohttp import web
async def http_handler(request):
try:
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/plain',
'Connection': 'close',
},
)
block_size = 1024
await response.prepare(request)
await response.write(f'Server msg: Read {block_size} bytes of data\n'.encode())
await request.content.read(block_size)
await response.write(b'Server msg: Wait before closing\n')
await asyncio.sleep(5)
await response.write(b'Server msg: Close connection\n')
await response.write_eof() # create zero block in chunked transfer encoding
return response
except Exception as e:
return web.Response(text=f"Server exception: {e}", status=500)
async def create_app():
app = web.Application()
app.router.add_put('/upload', http_handler)
return app
if __name__ == '__main__':
HOST = os.getenv('HOST', 'localhost')
PORT = int(os.getenv('PORT', '8000'))
app = create_app()
web.run_app(app, host=HOST, port=PORT)
requirements.txt
aiohappyeyeballs==2.4.4
aiohttp==3.10.11
aiosignal==1.3.1
async-timeout==5.0.1
attrs==25.3.0
frozenlist==1.5.0
idna==3.10
multidict==6.1.0
propcache==0.2.0
typing-extensions==4.13.2
yarl==1.15.2
fly.toml (partial, suboptimal)
[build]
builder = 'paketobuildpacks/builder:base'
[env]
PORT = '8080'
HOST = '0.0.0.0'
[processes]
app = "python server.py"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
size = 'shared-cpu-1x'
Testing
I deployed the app to ‘5ak6optkvz-debug-flyproxy.fly.dev’ for testing.
Little data
Just type something like hello, when it is reading data.
cat | curl --raw -v --request PUT --http1.1 --upload-file . 'https://5ak6optkvz-debug-flyproxy.fly.dev/upload'
* Trying 2a09:8280:1::73:7020:0:443...
* TCP_NODELAY set
* Connected to 5ak6optkvz-debug-flyproxy.fly.dev (2a09:8280:1::73:7020:0) port 443 (#0)
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
* subject: CN=*.fly.dev
* start date: Apr 25 23:26:07 2025 GMT
* expire date: Jul 24 23:26:06 2025 GMT
* subjectAltName: host "5ak6optkvz-debug-flyproxy.fly.dev" matched cert's "*.fly.dev"
* issuer: C=US; O=Let's Encrypt; CN=E6
* SSL certificate verify ok.
> PUT /upload HTTP/1.1
> Host: 5ak6optkvz-debug-flyproxy.fly.dev
> User-Agent: curl/7.68.0
> Accept: */*
> Transfer-Encoding: chunked
> Expect: 100-continue
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain
< connection: close
< transfer-encoding: chunked
< date: Sat, 03 May 2025 11:02:43 GMT
< server: Fly/f22fdaf3f (2025-04-30)
< via: 1.1 fly.io
< fly-request-id: 01JTAX04BKSVY1KRPG1Z6NX2GK-fra
<
24
Server msg: Read 1024 bytes of data
hello
20
Server msg: Wait before closing
1D
Server msg: Close connection
0
* we are done reading and this is set to close, stop send
* Closing connection 0
* TLSv1.3 (OUT), TLS alert, close notify (256):
In this case, it just takes about 7 seconds for the client to receive the final zero chunk.
Much data
Massive zeros and measuring the data size using pv
.
pv < /dev/zero | curl --raw -v --request PUT --http1.1 --upload-file . 'https://5ak6optkvz-debug-flyproxy.fly.dev/upload'
* Trying 2a09:8280:1::73:7020:0:443...
* TCP_NODELAY set
* connect to 2a09:8280:1::73:7020:0 port 443 failed: No route to host
* Trying 66.241.124.24:443...
* TCP_NODELAY set
* Connected to 5ak6optkvz-debug-flyproxy.fly.dev (66.241.124.24) port 443 (#0)
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
* subject: CN=*.fly.dev
* start date: Apr 25 23:26:07 2025 GMT
* expire date: Jul 24 23:26:06 2025 GMT
* subjectAltName: host "5ak6optkvz-debug-flyproxy.fly.dev" matched cert's "*.fly.dev"
* issuer: C=US; O=Let's Encrypt; CN=E6
* SSL certificate verify ok.
> PUT /upload HTTP/1.1
> Host: 5ak6optkvz-debug-flyproxy.fly.dev
> User-Agent: curl/7.68.0
> Accept: */*
> Transfer-Encoding: chunked
> Expect: 100-continue
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain
< connection: close
< transfer-encoding: chunked
< date: Sat, 03 May 2025 11:05:16 GMT
< server: Fly/f22fdaf3f (2025-04-30)
< via: 1.1 fly.io
< fly-request-id: 01JTAX4SQE62N8AZR4TTMWWMY6-fra
<
24
Server msg: Read 1024 bytes of data
20
Server msg: Wait before closing
1D81MiB 0:00:01 [4.78MiB/s] [ <=> ]
Server msg: Close connection
09.7MiB 0:00:14 [5.89MiB/s] [ <=> ]
* we are done reading and this is set to close, stop send
* Closing connection 0
* TLSv1.3 (OUT), TLS alert, close notify (256):
65.2MiB 0:00:15 [4.31MiB/s] [ <=>
In this case, it seems to consume > 60 MiB after the Close connection message. Because the duration is quite similar to the first case, it seems that there is a 5 to 7 seconds limit for the consumption.
Summary
I think, the issue may be that the fly.io proxy somehow caches or delays the final zero chunk, so that it doesn’t arrive instantly. The problem is, that in the meanwhile, it is accepting data that may go nowhere. There seems to be a hardcoded time limit. I believe this shorter time limit was introduced recently, because basically observed endless consumption before. It could even be the case, that the fly proxy doesn’t send the final zero chunk before it detects the TCP connection close. With HAProxy I still see, that the zero chunk is only sent and the connection terminated, if the client tries to upload more data.
The fly.io deployment with the default builder was pretty slow, so I didn’t test removing the close header in the streaming response. I could imagine that the fly proxy may continue to consume more data, if the uploading client does not end the connection itself (we are done reading and this is set to close, stop send). I will check if this is a possible bug in curl, or if this is protocol-compliant behavior. In my understanding, observing the end of the response should also terminate the upload.
You are free to test using my debug deployment of the reproducible example. I will keep it up for a week or so.