AudioProxy.Source.S3 (audio_proxy v0.4.0)

Copy Markdown View Source

The s3:// source type: an object in a bucket.

s3://masters/2026/piece-final.wav is a bucket and a key, split at the first /. Both halves are required — a source naming only a bucket, or only a key, is refused rather than guessed at.

The key is kept as its raw decoded bytes. AudioProxy.Source has already peeled off exactly one layer of encoding, and what is left is what S3 stores: a key is an opaque byte string, a b.wav and a+b.wav are different objects, and nothing here folds them together. Neither is a//b collapsed the way local:// collapses it — an empty path segment is a perfectly ordinary character in a key, and S3 will hand you back a different object for it.

Policy

The bucket is matched against AP_SOURCE_ALLOWLIST (AudioProxy.Source.Allowlist), where an unset list accepts everything: the proxy's S3 credentials already decide which buckets are readable, so an allowlist is a second, narrower gate rather than the only one. Refusals are {:error, :not_allowed} → 404, indistinguishable from a missing object.

Limits

A bucket may be 63 bytes and a key 1024 — S3's own maxima. Past them the object cannot exist, so refusing early costs nothing and keeps an unbounded string out of an argv element and a log line.

The whole body is bounded before it is split, not after. Finding the first / is a memchr-style scan, so it is cheap when a separator turns up early and linear in the whole body when none ever does — measured at 0.016 ms for 1 MB and 0.177 ms for 10 MB of separator-free input. That is four orders of magnitude short of the cost that made AudioProxy.Source.Local's cap a denial-of-service control, so this bound is a protocol bound like AudioProxy.Source.Https's rather than a scheduler defence. It is enforced first anyway: an input that cannot name an object should not be scanned at all, and the ordering is one less thing to re-derive later.

The seam

stat/1 is one AudioProxy.S3.head/2: the object's size answers the render path's 413 before a subprocess starts, and its ETag is what /info's validator hashes. ffmpeg_input/1 is one AudioProxy.S3.presign_get/3, handed to ffmpeg as a single argv element — the source bytes never cross the BEAM, and ffmpeg issues its own Range requests, so -ss on a two-hour master reads only the bytes it needs.

Nothing is presigned at stat/1 time. Both flows call the two callbacks separately, and a presigned URL has an expiry: minting one the caller may never use is a credential with a lifetime and no purpose.

Failures classify by cause

AudioProxy.S3's error type is five atoms and one {:http, status, _} whose status is unbounded, and this module maps all of it explicitly, with no catch-all — an unmapped shape should crash a test rather than pick a plausible status in production.

The status ranges are the part worth reading twice. An earlier revision of this covered 4xx and 5xx and called that total, which it is not: ex_aws turns S3's "your bucket is in another region" into {:http, 301, _}, and a request that hit it raised FunctionClauseError and answered a bare 500 — the exact outcome the no-catch-all rule exists to prevent, arrived at by leaving a hole instead of a default.

:not_found           :not_found            404, the blind row
:access_denied       :not_found            404, the blind row
{:http, 4xx, _}      :not_found            404, the blind row
{:http, 3xx, _}      :not_configured       500  a wrong-region redirect
:not_configured      :not_configured       500
{:http, 5xx, _}      :upstream_unavailable 502
{:transport, _}      :upstream_unavailable 502

A {:http, 1xx/2xx, _} still has no clause, deliberately: AudioProxy.S3 only builds those for the multipart write path, which this module never reaches. A 3xx other than 301 does not arrive either — ex_aws's own case has no branch for one and raises inside the dependency first, which is not something a clause here can repair.

Folding :access_denied into the 404 is the deliberate part. A bucket policy that denies HEAD is indistinguishable from a missing object to the client, which is the property §5's blind 404 exists to protect; the operator gets the truth from the log line, which names the S3 reason, rather than from a response body that would double as an existence oracle. A 4xx that is neither goes the same way: it means we asked wrongly for an object the client named, and the client cannot tell that apart from the object not being there.

An outage does not go there. {:transport, _} and an upstream 5xx say nothing about whether the object exists, so answering 404 would report a deletion that did not happen and edge-cache it for ten seconds, suppressing the retry that would have worked. They answer 502 (no-store).

:invalid_range is unreachable — it belongs to get_stream/3, which this module never calls — and so has no clause. If it ever appeared it would raise, which is this codebase's convention for "this should be impossible".

Summary

Types

t()

An S3 source: a bucket and an object key, both as written.

Functions

Maps one AudioProxy.S3.error/0 shape to this type's own reason.

A short human-readable sentence for one of this type's own reasons.

Every rejection reason this type can produce.

Types

t()

@type t() :: {:s3, String.t(), String.t()}

An S3 source: a bucket and an object key, both as written.

Functions

classify(arg1)

@spec classify(AudioProxy.S3.error()) ::
  :not_found | :not_configured | :upstream_unavailable

Maps one AudioProxy.S3.error/0 shape to this type's own reason.

One clause per shape, no catch-all — see the moduledoc's table for what each answers and why. Public so a test can enumerate the error type exhaustively: the mapping is the whole of this slice's decision, and a shape that quietly lost its clause would answer 500 to a request rather than fail a test.

message(atom)

@spec message(atom()) :: String.t()

A short human-readable sentence for one of this type's own reasons.

reasons()

@spec reasons() :: [atom()]

Every rejection reason this type can produce.

Declared rather than inferred, so a test can hold the two ends together: AudioProxy.ErrorJSON has no catch-all, so a reason with no row there raises FunctionClauseError in production. A new reason added here without a row there fails that test instead of a request.

Not all of them are 404s. :not_configured and :upstream_unavailable come from the storage seam and are deliberately not on AudioProxy.ErrorJSON.not_found_reasons/0 — see the moduledoc's table for why an outage must stay distinguishable from a missing object.