Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/template-upload-request-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'e2b': patch
'@e2b/python-sdk': patch
---

Template file uploads now send the request headers the file-upload-link response returns. Azure-backed clusters sign layer-file uploads with a SAS and return `x-ms-blob-type: BlockBlob`, which a SAS cannot carry; without it every uncached `COPY` in `Template.build()` failed with a storage `400 MissingRequiredHeader`. GCS and S3 clusters return no headers and are unaffected.
31 changes: 28 additions & 3 deletions packages/js-sdk/src/template/buildApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export async function uploadFile(
fileName: string
fileContextPath: string
url: string
headers?: Record<string, string>
ignorePatterns: string[]
resolveSymlinks: boolean
gzip: boolean
Expand All @@ -128,6 +129,7 @@ export async function uploadFile(
const {
fileName,
url,
headers,
fileContextPath,
ignorePatterns,
resolveSymlinks,
Expand All @@ -154,7 +156,7 @@ export async function uploadFile(
abortOpts?.signal
)

const res = await putFileStream(url, tar.path, tar.size, signal)
const res = await putFileStream(url, tar.path, tar.size, signal, headers)

if (!res.ok) {
throw new FileUploadError(
Expand All @@ -166,17 +168,37 @@ export async function uploadFile(
if (error instanceof FileUploadError) {
throw error
}
throw new FileUploadError(`Failed to upload file: ${error}`, stackTrace)
// fetch reports transport failures as a bare "TypeError: fetch failed";
// the actual reason is on `cause`.
const cause = (error as { cause?: { message?: string } } | null)?.cause
?.message
throw new FileUploadError(
`Failed to upload file: ${error}${cause ? ` (${cause})` : ''}`,
stackTrace
)
} finally {
await cleanup?.()
}
}

const FRAMING_HEADERS = new Set(['content-length', 'transfer-encoding'])

// The API's upload-header map is an open string map; a framing header from it
// would shadow or duplicate the archive's own Content-Length.
function withoutFramingHeaders(headers?: Record<string, string>) {
return Object.fromEntries(
Object.entries(headers ?? {}).filter(
([name]) => !FRAMING_HEADERS.has(name.toLowerCase())
)
)
}

async function putFileStream(
url: string,
filePath: string,
size: number,
signal: AbortSignal | undefined
signal: AbortSignal | undefined,
headers?: Record<string, string>
): Promise<{ ok: boolean; statusText: string }> {
// Prefer undici's fetch: it honors the explicit Content-Length on stream
// bodies on every runtime, while Deno's native fetch ignores the header and
Expand All @@ -192,7 +214,10 @@ async function putFileStream(
body: stream.Readable.toWeb(
fs.createReadStream(filePath)
) as ReadableStream,
// The upload link may require headers a signed URL cannot carry (Azure's
// Put Blob needs x-ms-blob-type); Content-Length is framing and wins.
headers: {
...withoutFramingHeaders(headers),
'Content-Length': size.toString(),
},
// Streaming request bodies require half-duplex mode.
Expand Down
3 changes: 2 additions & 1 deletion packages/js-sdk/src/template/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ export class TemplateBase
stackTrace = this.stackTraces[index + 1]
}

const { present, url } = await getFileUploadLink(
const { present, url, headers } = await getFileUploadLink(
client,
{
templateID,
Expand All @@ -1131,6 +1131,7 @@ export class TemplateBase
fileName: src,
fileContextPath: this.fileContextPath.toString(),
url,
headers,
ignorePatterns: [
...this.fileIgnorePatterns,
...readDockerignore(this.fileContextPath.toString()),
Expand Down
53 changes: 53 additions & 0 deletions packages/js-sdk/tests/template/uploadFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('uploadFile transfer encoding', () => {
let server: Server
let baseUrl: string
let capturedHeaders: IncomingMessage['headers'] = {}
let capturedHeaderNames: string[] = []
let capturedBodyLength = 0

beforeAll(async () => {
Expand All @@ -26,6 +27,9 @@ describe('uploadFile transfer encoding', () => {

server = createServer((req, res) => {
capturedHeaders = req.headers
capturedHeaderNames = req.rawHeaders
.filter((_, i) => i % 2 === 0)
.map((name) => name.toLowerCase())
let bytes = 0
req.on('data', (chunk: Buffer) => {
bytes += chunk.length
Expand Down Expand Up @@ -73,5 +77,54 @@ describe('uploadFile transfer encoding', () => {
// Content-Type (e.g. inferred from the archive's file extension) makes
// the storage backend reject the upload with 403 Forbidden.
expect(capturedHeaders['content-type']).toBeUndefined()

expect(capturedHeaders['x-ms-blob-type']).toBeUndefined()
})

test('sends the headers the upload link requires alongside Content-Length', async () => {
await uploadFile(
{
fileName: '*.txt',
fileContextPath: testDir,
url: baseUrl,
headers: { 'x-ms-blob-type': 'BlockBlob' },
ignorePatterns: [],
resolveSymlinks: false,
gzip: true,
},
undefined
)

expect(capturedHeaders['x-ms-blob-type']).toBe('BlockBlob')
expectIntactFraming()
})

test('drops a framing header the upload link returned', async () => {
await uploadFile(
{
fileName: '*.txt',
fileContextPath: testDir,
url: baseUrl,
headers: { 'x-ms-blob-type': 'BlockBlob', 'content-length': '1' },
ignorePatterns: [],
resolveSymlinks: false,
gzip: true,
},
undefined
)

expect(capturedHeaders['x-ms-blob-type']).toBe('BlockBlob')
expectIntactFraming()
})

function expectIntactFraming() {
expect(Number(capturedHeaders['content-length'])).toBe(capturedBodyLength)
expect(
capturedHeaderNames.filter((name) => name === 'content-length')
).toHaveLength(1)
expect(
(capturedHeaders['transfer-encoding'] ?? '').toLowerCase()
).not.toContain('chunked')
expect(capturedHeaders['content-type']).toBeUndefined()
}
})
118 changes: 118 additions & 0 deletions packages/js-sdk/tests/template/uploadHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { writeFile, mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createServer, type IncomingMessage, type Server } from 'http'
import { AddressInfo } from 'net'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { randomUUID } from 'node:crypto'

import { Template } from '../../src'
import { TEST_API_KEY, apiUrl } from '../setup'

// The file-upload-link response carries headers the signed URL cannot carry
// itself (Azure's Put Blob requires x-ms-blob-type); without them every
// uncached COPY fails with a storage 400 on an Azure-backed cluster.

let testDir: string
let uploadServer: Server
let uploadUrl: string
let capturedHeaders: IncomingMessage['headers'] = {}

let linkHeaders: Record<string, string> | undefined

const restHandlers = [
http.post(apiUrl('/v3/templates'), async ({ request }) => {
const { name } = (await request.clone().json()) as { name: string }
return HttpResponse.json({
buildID: randomUUID(),
templateID: name,
tags: [],
})
}),
http.get(apiUrl('/templates/:templateID/files/:hash'), () =>
HttpResponse.json({
present: false,
url: uploadUrl,
headers: linkHeaders,
})
),
http.post(apiUrl('/v2/templates/:templateID/builds/:buildID'), () =>
HttpResponse.json({})
),
]

const server = setupServer(...restHandlers)

beforeAll(async () => {
testDir = await mkdtemp(join(tmpdir(), 'uploadHeaders-test-'))
await writeFile(join(testDir, 'hello.txt'), 'hello world')

uploadServer = createServer((req, res) => {
capturedHeaders = req.headers
req.on('data', () => {})
req.on('end', () => {
res.writeHead(200)
res.end()
})
})
await new Promise<void>((resolve) =>
uploadServer.listen(0, '127.0.0.1', resolve)
)
const { port } = uploadServer.address() as AddressInfo
uploadUrl = `http://127.0.0.1:${port}/upload`

// Only the local upload server may go unmocked. print.error() alone still
// performs the request, so anything else is blocked by throwing.
server.listen({
onUnhandledRequest: (request, print) => {
if (new URL(request.url).hostname === '127.0.0.1') return
print.error()
throw new Error(`unhandled request: ${request.method} ${request.url}`)
},
})
})

afterAll(async () => {
server.close()
await new Promise<void>((resolve) => uploadServer.close(() => resolve()))
await rm(testDir, { recursive: true, force: true })
})

afterEach(() => {
capturedHeaders = {}
linkHeaders = undefined
})

test('upload PUT carries the headers the upload link returned', async () => {
linkHeaders = { 'x-ms-blob-type': 'BlockBlob' }

const template = Template({ fileContextPath: testDir })
.fromBaseImage()
.copy('*.txt', '.')

await Template.buildInBackground(template, 'upload-headers', {
apiKey: TEST_API_KEY,
})

expect(capturedHeaders['x-ms-blob-type']).toBe('BlockBlob')
expect(Number(capturedHeaders['content-length'])).toBeGreaterThan(0)
expect(
(capturedHeaders['transfer-encoding'] ?? '').toLowerCase()
).not.toContain('chunked')
expect(capturedHeaders['content-type']).toBeUndefined()
})

test('upload PUT is unchanged when the upload link returns no headers', async () => {
const template = Template({ fileContextPath: testDir })
.fromBaseImage()
.copy('*.txt', '.')

await Template.buildInBackground(template, 'upload-no-headers', {
apiKey: TEST_API_KEY,
})

expect(capturedHeaders['x-ms-blob-type']).toBeUndefined()
expect(Number(capturedHeaders['content-length'])).toBeGreaterThan(0)
})
20 changes: 19 additions & 1 deletion packages/python-sdk/e2b/template/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import re
import inspect
from types import TracebackType, FrameType
from typing import IO, List, Optional, Union
from typing import IO, Dict, List, Optional, Union

from e2b.exceptions import TemplateException
from e2b.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME
Expand Down Expand Up @@ -304,6 +304,24 @@ def tar_file_stream(
raise


_FRAMING_HEADERS = frozenset({"content-length", "transfer-encoding"})


def strip_framing_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
"""
Drop framing headers from an API-supplied header map.

The upload-link header map is an open string map; a framing header from it
would shadow or duplicate the archive's own Content-Length.

:param headers: Headers returned by the file-upload-link response
:return: The headers with any framing entry removed
"""
return {
k: v for k, v in (headers or {}).items() if k.lower() not in _FRAMING_HEADERS
}


def strip_ansi_escape_codes(text: str) -> str:
"""
Strip ANSI escape codes from a string.
Expand Down
18 changes: 14 additions & 4 deletions packages/python-sdk/e2b/template_async/build_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import os
from types import TracebackType
from typing import Callable, Optional, List, Union
from typing import Callable, Dict, Optional, List, Union

import httpx
from pyqwest import HTTPTransport
Expand Down Expand Up @@ -42,7 +42,11 @@
TemplateTagInfo,
)
from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS
from e2b.template.utils import get_build_step_index, tar_file_stream
from e2b.template.utils import (
get_build_step_index,
strip_framing_headers,
tar_file_stream,
)


async def request_build(
Expand Down Expand Up @@ -115,6 +119,7 @@ async def upload_file(
resolve_symlinks: bool,
gzip: bool,
stack_trace: Optional[TracebackType],
headers: Optional[Dict[str, str]] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

T-3a — new optional parameters are keyword-only from day one, enforced by a bare *. As written headers is a defaulted positional inserted ahead of request_timeout, so any caller binding request_timeout by position now silently passes it as headers. Internal today, but the rule exists to make that impossible; all call sites already use kwargs so adding * is safe.

Suggested change
headers: Optional[Dict[str, str]] = None,
*,
headers: Optional[Dict[str, str]] = None,

request_timeout: Optional[float] = None,
):
# Uploading a large build-context archive can take far longer than the 60s
Expand Down Expand Up @@ -155,11 +160,16 @@ async def upload_file(
# Stream the archive from disk via an async iterator. The
# explicit Content-Length suppresses chunked transfer
# encoding, which S3 presigned URLs reject; reqwest keeps the
# Content-Length framing for the streamed body.
# Content-Length framing for the streamed body. The link may
# also require headers a SAS cannot carry (Azure's Put Blob
# needs x-ms-blob-type); Content-Length wins over them.
response = await client.put(
url,
content=aiter_io_chunks(tar_file),
headers={"Content-Length": str(size)},
headers={
**strip_framing_headers(headers),
"Content-Length": str(size),
},
)
response.raise_for_status()
finally:
Expand Down
6 changes: 6 additions & 0 deletions packages/python-sdk/e2b/template_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing_extensions import Unpack

from e2b.api.client.client import AuthenticatedClient
from e2b.api.client.types import Unset
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.template.consts import GZIP, RESOLVE_SYMLINKS
from e2b.template.logger import LogEntry, LogEntryEnd, LogEntryStart
Expand Down Expand Up @@ -137,6 +138,11 @@ async def _build(
resolve_symlinks,
gzip,
stack_trace,
headers=(
None
if isinstance(file_info.headers, Unset)
else file_info.headers.to_dict()
),
request_timeout=request_timeout,
)
if on_build_logs:
Expand Down
Loading
Loading