REST is a convention for HTTP APIs: a URL names a resource, the HTTP method says what to do with it, and the status code tells the client how it went; its first digit is the class.
GET: read; no body, safe to repeat and cacheable.
POST: create something or trigger an action; not idempotent, so sending it twice may create two orders.
PUT: replace the whole resource; idempotent, repeating it leaves the same state.
PATCH: change only the fields you send.
DELETE: remove; idempotent.
Status classes: 2xx success (200, 201 Created, 204 No Content with an empty body), 3xx redirect (304 Not Modified after a cache check), 4xx your request is wrong (400, 401 not signed in, 403 not allowed, 404, 422, 429 slow down), 5xx the server failed.
Idempotency is why the method matters on the client: it decides which requests are safe to retry automatically.
iOSMiddle
iOS Networking
URLSession, Codable, async APIs, interceptors, error handling
Questions closed
0 / 25
- 0Review
- 4Asked often
Questions
The system resolves the host through DNS, opens a TCP or QUIC connection, negotiates TLS, sends the method, headers and body, then reads back a status, headers and body; in Swift all of that is one try await session.data(for:) call.
Connection reuse: the session keeps connections open, so the next request to the same host skips DNS, TCP and TLS. HTTP/2 and HTTP/3 also carry many requests over one connection.
data(from: url) is a plain GET; data(for: request) takes a URLRequest with method, headers and body.
URLSession.shared is fine for simple calls; build your own session when you need timeouts, a cache, default headers or a delegate.
Two things the API deliberately leaves to you. A 404 or 500 is a completed request, not a thrown error, so cast the response to HTTPURLResponse and check statusCode. And decoding is a separate step with JSONDecoder, which throws its own errors you should keep apart from transport errors.
The old dataTask(with:completionHandler:) still works, but new code uses the async methods: they take part in task cancellation and avoid nested callbacks.
URL is only an address (scheme, host, path and query), while URLRequest wraps a URL with everything needed to send it: HTTP method, headers, body, timeout and cache policy.
URLRequest defaults to GET, a 60-second timeoutInterval and .useProtocolCachePolicy; data(from: url) sends exactly that default request.
It is a struct, so you build it as a var, and a modified copy never affects the original, which is handy when an interceptor adds headers.
Build the URL itself with URLComponents and URLQueryItem so query values are encoded, then wrap it in a request.
A data task returns the response body in memory, a download task writes it to a temporary file and hands you the file URL, and an upload task sends a body from memory or straight from a file.
Data (data(for:)): JSON and other small payloads; the whole body sits in RAM.
Download (download(for:)): large files; memory stays flat. The file lives in a temporary location the system cleans up, so move it where you need it right away. A cancelled download can resume from resume data.
Upload (upload(for:from:) or upload(for:fromFile:)): the file variant streams from disk instead of loading the file into memory.
Only download and upload tasks run in a background session, and uploads there must come from a file.
With the callback API every task is created suspended; nothing is sent until you call resume().
App Transport Security is the system policy that makes URLSession and WKWebView refuse insecure connections by default: plain HTTP, TLS older than 1.2, and certificates or ciphers below Apple's minimum.
A blocked request fails with NSURLErrorAppTransportSecurityRequiresSecureConnection (-1022) before anything is sent.
Exceptions go into Info.plist under NSAppTransportSecurity: per domain with NSExceptionDomains and NSExceptionAllowsInsecureHTTPLoads, or for web views with NSAllowsArbitraryLoadsInWebContent.
NSAllowsArbitraryLoads turns ATS off for everything; App Review asks you to justify it, so prefer the narrow exception.
NSAllowsLocalNetworking covers local addresses such as a dev server on your LAN.
ATS checks the quality of the connection, not who is on the other end; certificate pinning (NSPinnedDomains) is the separate step for that.
The term is interpolated into the URL string, so its & starts a new parameter and the server receives q=C++ ; query values must go through URLComponents and URLQueryItem, which encode each value.
URL(string:) parses; it does not encode characters that have a meaning in a URL. & starts the next parameter, # starts the fragment so q=C# sends C, and = separates name from value.
Since iOS 17 it percent-encodes characters that are invalid in a URL, such as a space, so the string no longer yields nil; before iOS 17 it did, and the ! crashed. The change hides this bug rather than fixing it.
URLComponents.queryItems encodes &, # and = inside each value, so C++ & Swift arrives as one value.
**The + gap**: + is legal in a query, so URLComponents leaves it alone and a server that decodes the query form-style reads it as a space. Replace it in percentEncodedQuery after setting queryItems.
addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) is not the fix: that set allows &, = and +.
The same goes for paths: appending(path:) escapes what you pass, interpolation does not.
default stores cache, cookies and credentials on disk; ephemeral keeps them only in memory and loses them with the session; background hands transfers to a system process so they continue while the app is suspended or terminated.
default: the normal API client. Its cache and cookies outlive a logout: clear URLCache and HTTPCookieStorage yourself, or the next user can see the previous user's cached responses.
ephemeral: private browsing, per-account isolation and tests. Dropping the session wipes everything, which makes it a clean way to reset state on sign-out.
background(withIdentifier:): large downloads and uploads that must finish without the app; delegate-only, uploads from a file only, and the system decides when they run.
URLSession.shared is a fixed singleton: no delegate, no configuration changes, and no background transfers.
The common bug: the session copies the configuration when it is created, so changing config.timeoutIntervalForRequest afterwards does nothing to the existing session.
Useful settings on any type: timeoutIntervalForRequest, httpAdditionalHeaders, waitsForConnectivity, allowsConstrainedNetworkAccess, urlCache, httpMaximumConnectionsPerHost.
It creates a new URLSession per request: every call pays a fresh TCP and TLS handshake because the connection pool belongs to the session, and every session keeps its delegate alive until it is invalidated, so nothing is released.
URLSession is designed to be long-lived: one per configuration, held for the lifetime of the app or the feature. Requests are cheap; sessions are not.
The connection pool belongs to the session. HTTP/2 multiplexes many requests over one TLS connection, and reuse is what removes the handshake round trips. A per-request session throws that away every time; Instruments shows a new handshake per call.
The leak: URLSession(configuration:delegate:delegateQueue:) holds the delegate strongly until finishTasksAndInvalidate() or invalidateAndCancel(). Nobody calls either here, so each call leaves a session and its connections behind, and memory climbs as the user scrolls.
The fix: one session created in init and stored, and since iOS 15 session.data(for:delegate:) for per-task callbacks; the task holds that delegate only until it completes.
URLSession.shared is fine for a plain GET but takes no delegate and no configuration.
Put one client between the app and URLSession that builds requests from typed endpoints, sends them, validates the status and decodes the body, and have it throw one error type whose cases match what the UI does next.
Endpoint: a value describing path, method and body, generic over the response type, so client.send(.users) returns [User] with no casts.
Client: the only place that knows the base URL, headers, the shared JSONDecoder and status validation. Cross-cutting work (auth, retry, logging) wraps it as interceptors instead of being repeated in every call.
Transport protocol: the client depends on a one-method protocol that URLSession conforms to, so tests inject a fake.
Errors by reaction: offline and timedOut (offer a retry, maybe queue the write), unauthorized (refresh or log out), server(status:body:) (show the server's message), decoding (a contract bug: log it, show a generic error), and cancellation, which is not an error for the user at all.
Keep the original URLError or DecodingError inside the case for logs; the UI should switch on your enum, never on raw codes.
Modern URLSession with async/await and Codable covers most apps in a few hundred lines you own; Alamofire pays off when you want its ready-made interceptors, retry policies, validation, multipart and logging instead of writing and testing them yourself.
**What you write yourself on URLSession: request building, status validation, an auth interceptor with a single shared refresh, retry with backoff, multipart bodies, logging. None of it is hard, but each piece has a known bug to avoid.
What Alamofire gives**: RequestInterceptor (adapt plus retry) for tokens, RetryPolicy, validate(), MultipartFormData, ServerTrustManager for pinning, EventMonitor for logging, and async APIs such as serializingDecodable(_:).
The cost: a dependency to keep updated across major versions, a bigger binary, and one more abstraction the team has to learn. It still runs on URLSession, so background-transfer and ATS limits are the same.
Avoid third-party networking in an SDK you ship to other teams: your dependency becomes their version conflict.
.iso8601 is strict: it needs a full date-time with a time zone, like 2026-03-01T12:00:00Z, so a date-only value, a time without Z or an offset, or a space instead of T throws; older OS versions also rejected fractional seconds.
The fix is a .custom strategy that tries the shapes the backend really sends: ISO8601DateFormatter with and without .withFractionalSeconds, then a DateFormatter for a zone-less or date-only shape. Fractional seconds only matter if your minimum deployment target still includes releases that reject them.
Try the common shape first and accept all of them. Many backends drop a .000, so a strategy that knows one shape fails exactly on the rows that land on a whole second.
For a format that is not ISO at all, DateFormatter needs locale = Locale(identifier: "en_US_POSIX") and an explicit timeZone. Without the POSIX locale, a device on a 12-hour clock or a non-Gregorian calendar parses HH:mm:ss differently and every date fails (for that user only), which is why it survives review.
Formatters are expensive; build them once. Create one configured JSONDecoder per client and share it; building one per response is a routine profiling finding on list screens.
The neighbouring trap: with keyDecodingStrategy = .convertFromSnakeCase the key is already createdAt by the time your CodingKeys are matched, so writing case createdAt = "created_at" matches nothing and throws keyNotFound.
Decoding an array is all-or-nothing: JSONDecoder throws on the first element it cannot build and you lose the other forty-nine, so make unknown values decodable and isolate the failure to a single element.
A String-backed enum gets a synthesised init(from:) that throws dataCorrupted for any raw value it does not know. Write the initialiser yourself and fall back to an unknown case; that alone fixes most "server shipped a new type" outages.
For everything else, decode the array through a wrapper that catches per element and keeps the rest. The trap inside that wrapper: a failed decode does not advance currentIndex in an UnkeyedDecodingContainer, so you must consume the bad element with a throwaway Decodable or the loop spins forever.
Optional plus a default beats a non-optional field for anything the server might stop sending; decodeIfPresent and a ?? are cheaper than an outage.
Log what you dropped. DecodingError's codingPath names the index and the key; "the data couldn't be read" without it is unactionable, and the count of dropped rows is the metric that tells you the contract broke.
Be liberal only where being liberal is safe. Skipping a feed card is fine; silently skipping a transaction or a permission flag is worse than a visible error, so keep those fields required and let the request fail loudly.
Rule of thumb: tolerate unknown values, never tolerate unknown meaning.
Cancel by cancelling the Task that awaits the request, which cancels the underlying URLSessionTask and throws CancellationError or URLError(.cancelled); bound waiting with timeoutIntervalForRequest and timeoutIntervalForResource, and never show a cancellation to the user as an error.
Task cancellation: task.cancel() propagates into the underlying URLSessionTask, and the transfer stops. Structured children are cancelled with their parent, and .task on a SwiftUI view cancels when the view disappears.
Per-request timeout: URLRequest.timeoutInterval, or on the session configuration timeoutIntervalForRequest (the gap allowed between bytes) and timeoutIntervalForResource (the ceiling for the whole transfer).
A manual deadline: race the work against a sleeping task inside withThrowingTaskGroup when the API you are calling has no timeout of its own.
The catch block must separate CancellationError from real errors before showing an alert. Debounce plus cancel-previous is the standard typeahead: each keystroke cancels the in-flight task, so only the last query reaches the network.
The guard … else { return } exits without resuming the continuation, so when decoding fails (an HTML 502 page, a changed contract) or data is nil, the awaiting task stays suspended forever and the spinner never stops.
The contract: a continuation must be resumed exactly once on every path. Zero times leaks the task and hangs whatever awaits it (a .task, a TaskGroup); twice crashes the checked variant.
Why it hides: try? turns the DecodingError into nil, and the guard folds it together with the missing-data case into a silent return. The happy path works, so the bug shows up only when the server misbehaves.
The fix: resume with an error on the nil-data path, and wrap the decode in continuation.resume(with: Result { … }), which turns a thrown error into a failure and resumes in one place.
Catching it: withCheckedThrowingContinuation logs "SWIFT TASK CONTINUATION MISUSE: … leaked its continuation" when the closure is released unresumed; watch the console, and keep a unit test that feeds the callback an HTML body and nil data and expects a thrown error. Never switch to withUnsafeThrowingContinuation to silence it.
When the API already has an async variant, such as URLSession.data(for:), call it instead of bridging by hand.
A drop after the request has started fails it with URLError.networkConnectionLost: retry an idempotent request with backoff, and set waitsForConnectivity so requests that cannot start yet wait; do not gate calls on a reachability check.
waitsForConnectivity only covers the time before a connection exists. Once bytes are in flight, a dropped connection fails the task at once with networkConnectionLost; a GET is retried, a POST needs an idempotency key (see the retry question).
Pair it with timeoutIntervalForResource as the ceiling for the whole transfer, and implement urlSession(_:taskIsWaitingForConnectivity:) as the cue to show an offline banner instead of an error alert.
Never gate a call behind a reachability check. Reachability describes the past, not whether this request will succeed, and a captive portal reports a perfectly good path.
NWPathMonitor from the Network framework is the current API for observing connectivity, and it is for UI decisions (an offline banner, a disabled Send button, resuming a queue), not for guarding every request.
Separate the errors that mean try later, notConnectedToInternet and networkConnectionLost, from real failures; only the first group belongs in a retry queue.
Queue the user's writes locally and replay them when the path returns, showing a pending state rather than throwing the work away.
Retry only failures a later attempt could plausibly fix, and space attempts with exponential backoff plus random jitter so a recovering server is not hit by every device at the same instant.
Retryable: transport failures such as timeouts and a dropped connection, plus 5xx and 429 from the server. When the response carries Retry-After, honour it instead of your own schedule.
Not retryable: the 4xx family that describes your request: 400, 403, 404, 422. A 401 is not a retry either, it is a token refresh followed by one replay.
Backoff: wait base times two to the power of the attempt, capped at a few seconds, then add random jitter. Without jitter every client that failed together retries together and the outage repeats.
Idempotency: GET, PUT and DELETE are safe to repeat, POST is not. For payments or order creation send an idempotency key the server can de-duplicate on, then retrying is safe.
Bound it: two or three attempts, one overall deadline, and no further attempt once the task is cancelled. Check cancellation across the sleep so a dismissed screen stops retrying.
Do not stack retries in layers: three in the client times three in the repository times three in the UI is twenty-seven requests for one tap.
URLCache is the HTTP cache built into URLSession: Cache-Control: max-age lets it answer with no request at all, and an ETag lets it revalidate so the server replies 304 with no body.
URLCache is active on a default session: a response carrying Cache-Control or Expires is replayed from memory or disk with no round trip at all. Size it yourself with URLCache(memoryCapacity:diskCapacity:directory:) on the configuration, because the default disk budget is small.
Validation is the other half. Keep the ETag (or Last-Modified) from the last response and send it back as If-None-Match (or If-Modified-Since); the server answers 304 with no body, so you pay one small round trip instead of the payload.
URLRequest.cachePolicy picks behaviour per request: the default honours the headers, .reloadIgnoringLocalCacheData is right for pull-to-refresh and for a request where you are doing the validation yourself, .returnCacheDataElseLoad gives an instant first paint.
Know the exclusions: an ephemeral session caches in memory only, and POST responses are not cached.
Cached bodies belong to whoever was signed in: on logout call removeAllCachedResponses() on the session's cache, or the next account sees the previous one's feed.
The HTTP cache is a bandwidth optimisation, not offline support. Content the user must still see tomorrow belongs in your own store.
URLSession has no multipart builder, so you assemble the body yourself: a unique boundary string, one part per field carrying its own Content-Disposition header, and a closing boundary that ends in two extra dashes.
upload(for:from:): the async method for a body you already hold in memory; it returns (Data, URLResponse) just like data(for:).
upload(for:fromFile:) streams a file from disk, but for multipart that file must be the complete multipart body, so write the parts to a temporary file first.
Set the request's Content-Type to multipart/form-data; boundary=<your boundary>, and use CRLF line endings inside the body; a bare newline is rejected by many servers.
For progress, pass a URLSessionTaskDelegate to the async upload call and implement didSendBodyData.
For an upload that must survive the app being suspended, build the session from URLSessionConfiguration.background(withIdentifier:). Background sessions accept file-based uploads only, never in-memory Data, and completion arrives through the delegate after relaunch.
Hand-built bodies usually fail in two places: a missing CRLF before the closing boundary, and holding a large file in Data on a memory-tight device.
Two techniques cover it: inject a protocol you can fake when you are testing your own client, and register a URLProtocol subclass when the real URL loading path is what you need to exercise.
Protocol injection: declare a tiny HTTPTransport protocol with the one method you use, conform URLSession to it in production and pass a stub in tests. Fast, synchronous to set up, and the default choice for testing your repository or view model.
URLProtocol stub: subclass it, return canned Data and an HTTPURLResponse from startLoading(), and install it through configuration.protocolClasses. This is the only way to test the code that actually builds requests, so headers, cache policy and status-code mapping are covered.
Build the session from a fresh URLSessionConfiguration.ephemeral in each test rather than registering the stub globally, otherwise stubs leak between tests and failures become order-dependent.
Test the paths that really break: a 500, a 204 with an empty body, malformed JSON, a URLError(.timedOut), and cancellation midway.
Keep decoding contract tests over recorded fixtures so a server-side rename fails the suite, and keep live-network tests out of the unit suite entirely.
A WebSocket keeps one two-way connection open so the server can push messages the moment they happen (live prices, chat, multiplayer); URLSessionWebSocketTask gives you send and receive, while the read loop, keepalive and reconnect are yours to write.
webSocketTask(with:) then resume() opens the socket, send(.string) or send(.data) writes, and receive() delivers exactly one message, so you must call it again for the next one.
Wrap that loop in an AsyncThrowingStream so the screen can for await the messages and so cancelling the consuming task tears the socket down with it.
Keep it alive with sendPing(pongReceiveHandler:) on a timer; intermediaries routinely drop a socket that has been idle for under a minute.
Reconnect with backoff and jitter, and resubscribe after every reconnect. A client that silently reconnects but never re-sends its subscriptions is the classic production bug, Also request whatever was missed, by sequence number.
Handle backgrounding: iOS suspends the socket, so reconnect when the app returns to the foreground instead of assuming it survived. Close politely with cancel(with: .goingAway, reason: nil).
Pick the transport deliberately. For one-way updates server-sent events or polling are cheaper to run, and only a push notification reaches a user whose app is not running.
The app sends a short-lived access token (often a JWT) with every request and keeps a long-lived refresh token to get a new one; when several requests hit 401 together, they must share one refresh through an actor and then each replays once.
Two tokens
Access token: goes in Authorization: Bearer … and lives for minutes. A JWT is base64url-encoded JSON plus a signature: the app may read exp to refresh early, but only the server validates it.
Refresh token: lives for days or weeks and is sent only to the refresh endpoint. Both go to the Keychain, never UserDefaults; pick an accessibility class such as kSecAttrAccessibleAfterFirstUnlock if background work must refresh.
Third-party or SSO sign-in is OAuth 2.0 authorization code with PKCE through ASWebAuthenticationSession, never a web view that sees the password.
Single-flight refresh
Ten requests fail at once; without coordination they fire ten refreshes. With refresh-token rotation the server invalidates the old refresh token on first use, so the other nine fail and the user is logged out.
An actor holds the in-flight Task, and later callers await the same task. Actor reentrancy is what makes it work: while the first caller is suspended, the others enter and find the task.
Pass in the token the failed request used. If the stored token is already different, someone refreshed in the meantime, so just replay.
Edge cases
The refresh request bypasses the interceptor, or a 401 on refresh triggers another refresh forever.
Replay exactly once. If the refresh fails with invalid_grant, clear the Keychain and route to login.
Do not blindly replay a non-idempotent POST; give it an idempotency key.
data(for:) buffers the whole body in memory before it returns, while bytes(for:) hands you the response as an AsyncSequence you consume as it arrives, so peak memory is one record instead of the entire payload.
let (bytes, response) = try await session.bytes(for: request) gives a URLSession.AsyncBytes. Check the HTTPURLResponse status before you start consuming: an error body is not what your row decoder expects.
bytes.lines is an AsyncSequence of String and fits NDJSON, where every record is one line. It skips empty lines, so it cannot find the blank line that ends a server-sent event; for SSE read the raw bytes and split on the blank line yourself.
Backpressure is real. The sequence only reads ahead a little, so a slow consumer slows the transfer instead of inflating memory, and breaking out of the for try await loop, or cancelling the task, cancels the underlying URLSessionTask.
The trap is what you do inside the loop: appending every decoded row to one array is data(for:) with extra steps, and writing each row to the store on the main actor turns a memory problem into a frozen UI. Batch a few hundred rows and write them off the main actor.
If the payload is a file rather than records, do not stream it by hand: a download task writes straight to disk and gives you a URL. In a background session it must be downloadTask(with:) plus a URLSessionDownloadDelegate; the async download(for:) is not allowed there.
Rule of thumb: memory should scale with one record, never with the response.
Pinning makes the app accept a server only if its certificate chain contains a key you shipped in the app, so a man-in-the-middle holding a certificate from any other trusted CA (a corporate proxy, a compromised CA, a root the user was talked into installing) is rejected.
Implementation
Declarative: NSPinnedDomains in Info.plist (iOS 14+) with NSPinnedLeafIdentities or NSPinnedCAIdentities, each an SPKI-SHA256-BASE64 hash. No code, and the rule applies to every URLSession request to that domain.
In code: handle the server-trust challenge in a URLSessionDelegate: run the normal SecTrustEvaluateWithError first, then compare the keys in the chain with your pins. Pinning is in addition to system validation, never instead of it.
Pin the public key (SPKI), not the certificate file: a certificate can be reissued with the same key, so a key pin survives renewal; agree on that with the backend team. Compute the pin the way you store it (the code below uses SHA-256 of the raw key bytes); NSPinnedDomains expects the hash of the full SPKI structure.
What goes wrong
Rotation bricks the app: the day the server switches to a key you did not pin, every installed version fails and users must update. Always ship a backup pin for the next key, and pin an intermediate CA if the backend cannot promise key reuse.
Debugging proxies such as Charles or Proxyman stop working; disable pinning in debug builds only.
It does not protect against a user who controls their own device (a jailbreak can hook the check); it protects the user from others on the network.
It is worth it for banking, payments and health. For a typical app ATS plus the system trust store is enough, and the operational risk outweighs the gain.
A background session hands the transfer to a system daemon that keeps working while your app is suspended or terminated, and reports back by relaunching the app.
Build it from URLSessionConfiguration.background(withIdentifier:), one identifier per session, and keep a single instance for that identifier; creating a second one with the same id is undefined behaviour.
Background sessions are delegate-only. There is no async data(for:) and no completion handler, so you implement URLSessionDownloadDelegate and move the file out of the temporary URL inside didFinishDownloadingTo synchronously, because the system deletes it as soon as that method returns.
Uploads must come from a file, never from in-memory Data.
Relaunch handling is the part people forget: the system calls application(_:handleEventsForBackgroundURLSession:completionHandler:), you store that handler, recreate the session with the same identifier, and call the handler on the main queue from urlSessionDidFinishEvents.
A user force-quit cancels the transfer and the system will not relaunch the app for it; only system-initiated termination is covered.
The system decides when the transfer actually runs, guided by power and network conditions; isDiscretionary and earliestBeginDate express preferences, so never promise the user a completion time.
Progress arrives through didWriteData, and a cancelled download can resume later through cancel(byProducingResumeData:) and downloadTask(withResumeData:).
Two traps worth naming: URLSession.shared cannot do any of this, and after a relaunch there is no view controller waiting, so all the state the delegate needs must already be on disk.
Model each concern as a middleware that receives the request plus a next closure it must call, then fold the array into one send function: the position in the array is the nesting order, and the nesting order is the behaviour.
The shape is one method: intercept(_ request: URLRequest, next: Send) async throws -> (Data, HTTPURLResponse), where Send is the same signature. A middleware may modify the request, call next zero, one or many times, and modify or replace what comes back.
Compose by folding the array from the back over the transport closure, so element zero ends up outermost. That single reduce replaces the chain of wrapper classes people usually write.
Order is the design. Auth outside retry stamps the header once, so every retry replays the same, possibly stale, token; retry outside auth passes through auth on each attempt, so every attempt carries the current header. Logging outermost sees one line per call, innermost sees one per attempt, and metrics have to sit against the transport to time the real transfer.
A middleware that starts a different request (refreshing a token, fetching a config) must call the transport directly, never next, or it re-enters the whole chain and can loop forever.
Middlewares are shared across concurrent tasks, so they are Sendable values; anything mutable (the token, a retry budget) lives in an actor behind them.
URLProtocol is the wrong tool for this in production: it sits below the session and cannot cleanly await your actors; keep it for tests.
Rule of thumb: one array declares the whole request pipeline, and reading it top to bottom tells you what happens to a request.