Class HistoricalClient
- Namespace
- DatabentoDotNet.Historical
- Assembly
- DatabentoDotNet.Historical.dll
A client for Databento's historical HTTPS API: metadata, symbology, timeseries queries older than 24 hours, and batch jobs.
public sealed class HistoricalClient : IAsyncDisposable
- Inheritance
-
HistoricalClient
- Implements
- Inherited Members
Examples
using DatabentoDotNet;
using DatabentoDotNet.Dbn;
using DatabentoDotNet.Historical;
using NodaTime;
// One long-lived client for the life of the process, not one per call: this type is safe for
// concurrent requests once configured.
await using var client = new HistoricalClient
{
ApiKey = new ApiKey(Environment.GetEnvironmentVariable("DATABENTO_API_KEY")!),
};
var request = new GetRangeParams
{
Dataset = "GLBX.MDP3",
Symbols = Symbols.From("ESH4"),
Schema = Schema.Trades,
DateTimeRange = DateRange.OnDay(new LocalDate(2024, 1, 2)).ToDateTimeRange(),
Limit = 10,
};
// Free, and it prices the request below rather than one assembled a second time by hand — which
// is the whole reason ToQuery() exists.
decimal cost = await client.Metadata.GetCostAsync(request.ToQuery());
if (cost > 0.01m)
{
Console.WriteLine($"${cost} is more than this program will spend.");
return;
}
// This one bills.
await using var reader = await client.Timeseries.GetRangeAsync(request);
await foreach (OwnedRecord record in reader.ReadRecordsAsync())
{
if (record.TryGet(out TradeMsg trade))
{
Console.WriteLine($"{DbnTime.ToInstant(trade.IndexTs)} {trade.Price} x {trade.Size}");
}
}
Remarks
Port of upstream's historical::Client (historical/client.rs) — its
request, check_warnings, check_http_error, handle_response and
handle_zstd_jsonl_response, which together are the whole HTTP transport every endpoint
sits on. This type is that transport and nothing else: the endpoints themselves arrive with
the subclient facades that group them —
#36–#39.
A facade with no endpoints on it would be a public empty class, so none is declared here.
Thread-safe for concurrent requests once configured, and deliberately so — the opposite
call from LiveClient. Everything below the public surface is one
HttpClient, which is documented as safe for concurrent use, plus properties that
are init-only and therefore frozen before the first request. Several
requests may be in flight on one instance, and the intended use is one long-lived client for
the life of a process rather than one per call. LiveClient is not thread-safe because
one live connection is one conversation with a gateway and its record loop is a single reader
by construction; nothing of the kind is true of independent HTTP requests, so nothing here
pretends otherwise.
No builder. Upstream's ClientBuilder<AK> is generic type-state whose only
purpose is to make "no API key" unrepresentable — build() exists only on
ClientBuilder<ApiKey>. C# 11 required init properties do exactly that
natively, checked by the compiler at every construction site. See PORTING.md §2, and
LiveClient for the precedent in this repo.
await using var client = new HistoricalClient { ApiKey = new ApiKey(key) };
var datasets = await client.SendJsonAsync(
HttpMethod.Get, "metadata.list_datasets", parameters: null, MyJson.Default.ListString, ct);
The transport is public, and that is a decision rather than an omission.
SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken) and the two readers are what the endpoint facades are built from, and
they are also the escape hatch for an endpoint this library has not wrapped yet: the API has
twenty (ROADMAP.md §5 lists them), and a caller who needs the twenty-first the week it ships
should not have to wait for a release. Upstream's equivalents are pub(crate); this repo
declares no InternalsVisibleTo anywhere, so "internal but tested" is not a shape
available here.
Constructors
HistoricalClient()
Creates a client. Configure it through the init properties.
public HistoricalClient()
Remarks
All three fields are lazy, and not for cost. An init accessor runs
after the constructor body, so ApiKey does not exist yet at the
point where an eager constructor would want to build the HttpClient and its
Authorization header from it. Deferring to first use is what makes
required init properties and a fully configured HttpClient
compatible at all. ExecutionAndPublication because this
type is documented as safe for concurrent requests: two threads racing into the first
request must get one client, not two, and the loser must not see a half-built one.
Fields
ApiVersion
The API version every request path is prefixed with.
public const int ApiVersion = 0
Field Value
JsonMediaType
The Accept every request carries unless one is given for it.
public const string JsonMediaType = "application/json"
Field Value
Properties
ApiKey
The API key to authenticate with. Validated when it is constructed.
public required ApiKey ApiKey { get; init; }
Property Value
Remarks
The type, never a string. That is what keeps the key out of a log line
or an exception message structurally rather than carefully:
ToString() is redacted, so formatting the object that
holds it cannot leak it. The key reaches the wire in exactly one place — the
Authorization header built in this file — and nowhere else, not as a query
parameter and not as a form field.
BaseUrl
public Uri? BaseUrl { get; init; }
Property Value
Remarks
The advanced knob, as upstream documents with_url: it exists for a test harness or
a proxy, and a caller pointing a production client somewhere other than Databento's own
gateway has almost certainly made a mistake. It is how this library's own tests reach
MockHistoricalGateway.
A path on this URL is preserved, which takes explicit work.
new Uri(new Uri("http://host/api"), "v0/x") is http://host/v0/x — combining
a relative URI with a base whose path has no trailing slash replaces that last
segment rather than appending to it, so a proxy mounted at /api would silently
lose its mount point. The effective base address is therefore normalised to end in
/ before anything is resolved against it.
Exceptions
- ArgumentException
The URL is not absolute.
Batch
The batch.* endpoints — jobs that produce files rather than a stream.
public BatchClient Batch { get; }
Property Value
Remarks
The fourth and last facade (#39), cached the same way and for the same reason. One of its eight methods costs money and the rest are free: SubmitJobAsync(SubmitJobParams, CancellationToken) bills for the whole range at once, and listing, inspecting and downloading a job cost nothing — a job's files stay fetchable until they expire, so a download can be retried or resumed without a second charge. Price a job with GetCostAsync(MetadataQueryParams, CancellationToken) before submitting it; ToQuery() narrows the parameters for exactly that.
DisposesHandler
Whether DisposeAsync() disposes Handler. Defaults to true, as HttpClient's own parameter does.
public bool DisposesHandler { get; init; }
Property Value
Remarks
Set it to false when the handler's lifetime belongs to somebody else —
which is the IHttpMessageHandlerFactory case, where the factory pools handlers
across clients and rotates them on its own schedule. Disposing one out from under it would
break every other client sharing it. Ignored when Handler is
null: a handler this client built is a handler this client disposes.
Gateway
The gateway to send requests to. Defaults to Bo1.
public HistoricalGateway Gateway { get; init; }
Property Value
Remarks
Ignored when BaseUrl is set.
Handler
The HttpMessageHandler to send through, or null to let HttpClient build its own.
public HttpMessageHandler? Handler { get; init; }
Property Value
Remarks
This exists for IHttpClientFactory, and the defect it fixes is not socket
exhaustion. HttpClient's own handler is a
SocketsHttpHandler whose PooledConnectionLifetime
defaults to infinite, so a client held as a singleton in a host that stays up for weeks
keeps talking to whatever address hist.databento.com resolved to on its first
request. A handler supplied here can bound that; nothing else on this type can.
A handler, not an HttpClient, and that is the whole design. Everything
this client puts on a request it builds — HTTP Basic from the ApiKey, the
validated User-Agent, the Accept header, the base address — is still built
here and still built once. Handing over the whole client would mean either mutating an
object this type does not own to attach the Authorization header, or letting the
caller attach it — and then the key has two paths to the wire, which is exactly what
ApiKey's redacted ToString() and the single-header rule
exist to prevent.
LoggerFactory
Where to send this client's log messages, or null for none.
public ILoggerFactory? LoggerFactory { get; init; }
Property Value
Remarks
This is how the API's X-Warning header surfaces, and it is the only route it has:
the alternative — a warnings property on every response — means every one of the API's
twenty endpoints (ROADMAP.md §5 lists them) returns a wrapper type instead of its payload,
and every caller unwrapping, to carry a header that is almost always absent. That was
rejected on cost, not on taste. See Internal/HistoricalLog.cs for the messages and
their event ids.
Left null, this resolves to Instance — no logging configured means no logging done, and nothing is formatted or allocated for a caller who never asked.
Metadata
The metadata.* endpoints — discovery, and what a request would cost.
public MetadataClient Metadata { get; }
Property Value
Remarks
The first of the four endpoint-group facades this client exposes; #38–#39 add the rest. Built once and cached, because this client is documented thread-safe for concurrent requests and a bare null-coalescing assignment would let two threads each build one.
Symbology
The symbology.* endpoints — what a symbol's instrument id was, and when.
public SymbologyClient Symbology { get; }
Property Value
Remarks
The second facade (#37), cached the same way and for the same reason. One endpoint, and it costs nothing to call.
Timeseries
The timeseries.* endpoints — the market data itself.
public TimeseriesClient Timeseries { get; }
Property Value
Remarks
The third facade (#38), cached the same way and for the same reason. The only one whose endpoints cost money: everything on Metadata and Symbology is discovery or a billing enquiry. Price a download with GetCostAsync(MetadataQueryParams, CancellationToken) before making it.
UpgradePolicy
How to handle DBN data from an older version than this library decodes natively. Defaults to UpgradeToV3, as upstream's builder does.
public VersionUpgradePolicy UpgradePolicy { get; init; }
Property Value
Remarks
Carried here because it is a property of the client rather than of a call — upstream keeps
it on Client and reads it from timeseries.get_range and the batch
endpoints — but nothing in this transport consults it. It is the DBN decoder's
input, and the first request whose body is DBN rather than JSON arrives with
#38.
UserAgentExtension
Text to append to this library's User-Agent, identifying the application built on
it, or null to send the library's own user agent alone.
public string? UserAgentExtension { get; init; }
Property Value
Remarks
Port of upstream's user_agent_ext (client.rs:384-387), which composes it the
same way: the library's user agent, a space, then this. The composed header goes through
Add(string, string) — the validating overload — so an
extension that is not a well-formed sequence of user-agent products and comments is
rejected when the first request is sent rather than silently reaching Databento's logs
malformed.
Methods
DisposeAsync()
Releases the underlying HttpClient.
public ValueTask DisposeAsync()
Returns
- ValueTask
A completed task; there is no asynchronous work to do.
Remarks
Idempotent, and safe on a client that never sent a request: the HttpClient is built on first use, so there is nothing to release until one has been. Using the client after this throws ObjectDisposedException rather than quietly building a second one.
GetPathAsync(string, IEnumerable<KeyValuePair<string, string>>?, CancellationToken)
Sends one GET to an arbitrary path on the configured host, carrying whatever request
headers are given, and returns the response with its headers read and its body still on the
socket.
public Task<HttpResponseMessage> GetPathAsync(string path, IEnumerable<KeyValuePair<string, string>>? headers = null, CancellationToken cancellationToken = default)
Parameters
pathstringThe path to fetch, resolved against the configured base URL. An absolute path — one beginning with
/, which is what AbsolutePath gives — replaces the base URL's path entirely, which is what makes this a faithful port ofUrl::join.headersIEnumerable<KeyValuePair<string, string>>Request headers to add, or null for none.
cancellationTokenCancellationTokenCancels the request.
Returns
- Task<HttpResponseMessage>
The response, headers read and body not yet buffered. The caller disposes it.
Remarks
Port of upstream's get_with_path (client.rs:128-137), which exists for exactly
one caller in either library: a batch file's download URL is given by the API
rather than composed from a slug, so it cannot go through SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken).
Only the path is used, and the host the API named is discarded — deliberately, and
upstream does the same. batch.list_files returns URLs on
api.databento.com while the API itself is reached at hist.databento.com;
upstream's base_url.join(path) keeps the configured scheme and authority and replaces
only the path, and #39 measured both hosts serving byte-identical responses for the same
path. Two things follow, and both are the reason to keep it rather than to "fix" it.
First, the API key never reaches a host the caller did not configure. The credential
travels on this request as it does on every other one, so following a server-supplied
absolute URL would be handing it to whatever host that URL named. Second, a test harness
pointed at by BaseUrl keeps working: the download goes to the same loopback
server as everything else, which is what lets this library's resumable-download tests run
against MockHistoricalGateway at all.
headers is what SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken) has no equivalent of, and
it exists for Range. Values go through
TryAddWithoutValidation(string, string) — the non-validating
overload — because a Range is a request header whose validated form
Headers models as a typed collection rather than as a
string, and round-tripping through that type to send back the value already in hand would
buy nothing.
Exceptions
- ArgumentException
pathis null or empty.- DatabentoApiException
The API answered with a non-success status.
- ObjectDisposedException
The client has been disposed.
PathFor(string)
The path a slug is served at, relative to the base URL: v0/{slug}.
public static string PathFor(string slug)
Parameters
slugstringThe API slug —
metadata.list_datasets,timeseries.get_range. Slashes are allowed, so a batch file's path —batch/download/{user}/{job}/{file}— is a slug like any other.
Returns
- string
The relative path.
Remarks
Relative, and with no leading slash, because that is the only form that composes:
new Uri(baseAddress, PathFor(slug)) appends to the base address' path, where a
leading slash would resolve against the authority and discard it.
MockHistoricalGateway.PathFor in this repo's test project returns the
absolute path, with its leading slash, because its job is to match a recorded
RecordedRequest.Path. Two different jobs that happen to share a name and a version
segment; neither calls the other, deliberately. The harness is written from the API's
documented behaviour rather than from this library, and a harness that computed the path
the same way the client does could not catch the client computing it wrongly.
slug is interpolated, not escaped, and that is the caller's
constraint to honour. Upstream's base_url.join(&format!("v{API_VERSION}/{slug}"))
does not escape either, so this is faithful — but faithful is not the same as safe, and a
slug is a path here rather than a value. A ? in one starts a query string and a
# starts a fragment, either of which silently truncates the path instead of
producing a rejected request. Endpoint slugs are literals in this library's own source and
cannot contain one; a batch file's path
(#39) is
server-supplied and is the one place a caller passes something it did not write, so
percent-encode there rather than widening this.
Exceptions
- ArgumentException
slugis null or empty.
ReadJsonAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken)
Reads response's body as one JSON document.
public static Task<T> ReadJsonAsync<T>(HttpResponseMessage response, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
Parameters
responseHttpResponseMessageThe response to read.
typeInfoJsonTypeInfo<T>The source-generated metadata for
T.cancellationTokenCancellationTokenCancels the read.
Returns
- Task<T>
The deserialized body.
Type Parameters
TThe type to deserialize into.
Remarks
Port of the tail of upstream's handle_response (client.rs:207-209). It does
not dispose response; SendJsonAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken) is the composed form
that does.
A JsonTypeInfo<T> rather than a plain T, and that
signature is not negotiable here. This assembly is trim- and AOT-analysed with
warnings as errors, so the reflection-based JsonSerializer overloads do not
merely allocate a metadata cache at run time — they fail the build (IL2026/IL3050). Each
endpoint therefore supplies its own [JsonSerializable] context and passes the
generated type info in, which is also what lets a consumer publish this library
AOT-compiled at all.
static, because it needs nothing from the client — the response is
the caller's and the decode is pure. CA1822 is what raised the question and this repo
treats warnings as errors, but the answer would be the same without it: the alternative
was to give the method a use for instance state it does not have, and inventing a
disposed-client guard for a read that touches no client state would make
await using-scoping a client and then finishing a response you already hold throw
for no reason.
A decode failure here is thrown and not logged, deliberately. Upstream logs one
(deserialize_json, client.rs:231-236) and this port does not; the rule that
decides which of upstream's tracing sites are ported is on
Internal/HistoricalLog.cs's type remarks, and this is the case it rules out. The
short version: the JsonException reaches the caller carrying
Path, LineNumber and
BytePositionInLine, so a log line would duplicate what they
already hold.
Exceptions
- ArgumentNullException
An argument is null.
- JsonException
The body is not valid JSON, or is the literal
null.
ReadZstdJsonLinesAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken)
Reads response's body as a zstd frame containing one JSON document per
line.
public static Task<IReadOnlyList<T>> ReadZstdJsonLinesAsync<T>(HttpResponseMessage response, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
Parameters
responseHttpResponseMessageThe response to read.
typeInfoJsonTypeInfo<T>The source-generated metadata for
T.cancellationTokenCancellationTokenCancels the read.
Returns
- Task<IReadOnlyList<T>>
One element per non-blank line, in the order they arrived.
Type Parameters
TThe type each line deserializes into.
Remarks
Port of upstream's handle_zstd_jsonl_response (client.rs:212-229). The frame
is in the body rather than announced in Content-Encoding, which is why this
decompresses the stream itself instead of leaving it to HttpClient's
automatic decompression — there is no Content-Encoding for that to act on. A line
that is empty or entirely whitespace is skipped, which is wider than the trailing
newline a line-oriented writer leaves behind and is deliberately so: a lone \r
surviving a CRLF split, or a line of spaces, is no more a JSON document than an empty one
and would otherwise fail the whole read.
No historical endpoint calls this, and that is not an oversight. Upstream's
handle_zstd_jsonl_response has zero call sites in src/historical/; all four
are in src/reference/ — security.rs:49 and :76,
corporate.rs:58, adjustment.rs:50 — which is M4, the reference-data
milestone. It lives here because upstream defines it here, so a reader comparing the two
files finds it where they expect, and because the harness already serves exactly this
shape, so it could be tested against an oracle written before it existed.
Takes a JsonTypeInfo<T> for the reason given on ReadJsonAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken): the reflection-based overloads fail this assembly's build. It is static for the reason given there too.
This is the buffering half of a pair. ReadZstdJsonLinesStreamAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) is the streaming half: it yields each row as it decompresses instead of collecting them, and it is the one to reach for when the response is larger than the working set or the caller wants the first row before the last one has arrived. This one is what a caller who wants the whole list — to sort it, to count it, to index into it — should keep using.
Exceptions
- ArgumentNullException
An argument is null.
- JsonException
A line is not valid JSON, or is the literal
null.
ReadZstdJsonLinesStreamAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken)
Reads response's body as a zstd frame containing one JSON document per
line, yielding each row as it decompresses rather than collecting them all first.
public static IAsyncEnumerable<T> ReadZstdJsonLinesStreamAsync<T>(HttpResponseMessage response, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
Parameters
responseHttpResponseMessageThe response to read.
typeInfoJsonTypeInfo<T>The source-generated metadata for
T.cancellationTokenCancellationTokenCancels the enumeration.
Returns
- IAsyncEnumerable<T>
One element per non-blank line, in the order they arrived.
Type Parameters
TThe type each line deserializes into.
Remarks
The streaming half of a pair. ReadZstdJsonLinesAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) is the buffering half: it returns an IReadOnlyList<T> once the whole body has been read, and it stays the non-streaming path for a caller who wants the complete list — to sort it, to count it, to index into it. This one holds one row at a time, so a response larger than the working set costs the same as a small one and the first row is available before the last has left the server.
Rows come out in the order they arrived, and this method claims nothing more than
that. Upstream's handle_zstd_jsonl_response (client.rs:212-229) returns a
Vec<R> precisely so that its callers can sort it, and all four of them do:
reference/security.rs:50-53 by index and :77 by ts_effective,
corporate.rs:59-63 by index, adjustment.rs:51 by ex_date. A
stream cannot be sorted — sorting is what buffering is — so this does not sort, and
the documented order is the server's own. Whether that differs from upstream's order
observably depends on whether the server already returns rows sorted, which is a question
about the live API that no mock can answer (a double returns the lines it was handed, so it
agrees with whatever we assumed) and which
#57 owns. There is
deliberately no sorting overload here: each reference endpoint decides for itself whether it
sorts, over the buffering reader.
Blank-line tolerance and null-literal rejection are the buffering reader's, not a
second reading of them. A line that is empty or entirely whitespace is skipped; a line
that is the JSON literal null throws JsonException with the same
message. A difference between the two paths would be a bug in one of them, so the tests pin
them to each other rather than asserting each in isolation.
The argument checks run at the call, not at the first MoveNextAsync, and that
costs a split. A C# iterator method runs no part of its body until it is enumerated, so
an await foreach-less caller who passed null would get no exception
at all — the bug would be silent rather than late. Everything above returns
IAsyncEnumerable<T> from an ordinary method that validates and then hands back
a private iterator, which is what JsonSerializer's own
DeserializeAsyncEnumerable does and what makes this behave like
ReadZstdJsonLinesAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) at the call site. The consequence is that
[EnumeratorCancellation] sits on the private iterator instead; a caller's
WithCancellation still reaches it, because this method returns that iterator's
enumerable unchanged.
Disposing the enumerator disposes the decompression stream and the body stream —
which is what an await foreach does on its way out, an early break included.
response itself is the caller's to dispose, as it is on the buffering
reader; SendZstdJsonLinesStreamAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken) is the composed form that owns it.
Exceptions
- ArgumentNullException
An argument is null.
- JsonException
A line is not valid JSON, or is the literal
null.- OperationCanceledException
cancellationTokenwas cancelled.
SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken)
Sends one request to v0/{slug} and returns the response, having already logged any
server warnings it carried and thrown if the API rejected it.
public Task<HttpResponseMessage> SendAsync(HttpMethod method, string slug, IEnumerable<KeyValuePair<string, string>>? parameters, string? accept = null, CancellationToken cancellationToken = default)
Parameters
methodHttpMethodThe HTTP method.
POSTforms its parameters; anything else queries them.slugstringThe API slug, without the version prefix.
parametersIEnumerable<KeyValuePair<string, string>>The request parameters, or null for none.
acceptstringAn
Acceptfor this request alone, overriding the client's JsonMediaType default, or null to send the default. It has exactly one caller in this library:timeseries.get_rangeasks forapplication/octet-streamand is the only request in the whole historical API whose response is not JSON — upstream says as much in its own comment athistorical/timeseries.rs:141. It is set on the request rather than on the client's default headers, which every request this client will ever send shares.cancellationTokenCancellationTokenCancels the request.
Returns
- Task<HttpResponseMessage>
The response, headers read and body not yet buffered.
Remarks
The one primitive every endpoint is built from. Port of upstream's request
(client.rs:144-154) together with the check_warnings then
check_http_error pair that every one of its response handlers opens with
(client.rs:205-206).
parameters travel by HTTP method, not by endpoint. Upstream has
two families — add_to_query and add_to_form — and which one an endpoint uses
is decided entirely by its method: every GET in the crate queries and every
POST forms. So a POST sends an
application/x-www-form-urlencoded body and anything else sends a query string. One
rule, and no per-endpoint table for a future endpoint to be missing from — and no method
for which the parameters would silently go nowhere.
A null or empty parameters on a POST is an
empty form, not an absent body. The distinction is on the wire: an absent body
carries no Content-Type, and a server that branches on
application/x-www-form-urlencoded — Databento's does, and so does this repo's
harness — sees a different request.
Values are percent-encoded with EscapeDataString(string), which escapes
every reserved character rather than only the ones a URI parser would choke on. That
matters for one parameter above all: a Symbols list renders as AAPL,MSFT,
and the comma has to arrive as %2C — a comma is a sub-delimiter, and a server
splitting on raw ones would see a differently shaped request rather than a rejected one.
The response is returned with its headers read and its body still on the socket (ResponseHeadersRead), and it is the caller's to dispose. That is not an optimisation: #38 streams bodies larger than memory, and buffering the whole response before returning it is exactly what that endpoint cannot afford. Establishing it here means no endpoint has to remember to.
Nothing leaks on the throwing path: the error body is read, the response disposed, and only then is the exception raised.
The HttpRequestMessage is disposed when this method returns, while the
response body may still be arriving. That is safe — the request has been written in
full by the time HttpClient hands back headers, and nothing about reading
the response consults it — but it has one visible consequence worth knowing before
investigating it: response.RequestMessage.Content is a disposed
HttpContent on a response held across a long read, so a caller retrying a
download must rebuild the request rather than resend that one.
cancellationToken is last, after accept. Both
are optional, and the reverse order — the one this method was specified with — does not
build here: CA1068 requires a CancellationToken to be the last parameter, and
this repo treats warnings as errors. There is no suppression of it, and the ordering is
not a matter of taste worth one: a caller who passes a token positionally into an
accept slot gets a compile error rather than a request that quietly cannot be
cancelled.
Exceptions
- ArgumentNullException
methodis null.- ArgumentException
slugis null or empty.- FormatException
acceptis not a media type.- DatabentoApiException
The API answered with a non-success status.
- ObjectDisposedException
The client has been disposed.
SendJsonAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken)
Sends a request and reads its body as one JSON document — SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken) and ReadJsonAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) composed, with the response disposed.
public Task<T> SendJsonAsync<T>(HttpMethod method, string slug, IEnumerable<KeyValuePair<string, string>>? parameters, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
Parameters
methodHttpMethodThe HTTP method.
slugstringThe API slug, without the version prefix.
parametersIEnumerable<KeyValuePair<string, string>>The request parameters, or null for none.
typeInfoJsonTypeInfo<T>The source-generated metadata for
T.cancellationTokenCancellationTokenCancels the request.
Returns
- Task<T>
The deserialized body.
Type Parameters
TThe type to deserialize into.
Remarks
The shape almost every endpoint wants, and upstream's handle_response end to end.
Exceptions
- DatabentoApiException
The API answered with a non-success status.
SendZstdJsonLinesAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken)
Sends a request and reads its body as zstd-framed JSON lines — SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken) and ReadZstdJsonLinesAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) composed, with the response disposed.
public Task<IReadOnlyList<T>> SendZstdJsonLinesAsync<T>(HttpMethod method, string slug, IEnumerable<KeyValuePair<string, string>>? parameters, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
Parameters
methodHttpMethodThe HTTP method.
slugstringThe API slug, without the version prefix.
parametersIEnumerable<KeyValuePair<string, string>>The request parameters, or null for none.
typeInfoJsonTypeInfo<T>The source-generated metadata for
T.cancellationTokenCancellationTokenCancels the request.
Returns
- Task<IReadOnlyList<T>>
One element per non-blank line, in the order they arrived.
Type Parameters
TThe type each line deserializes into.
Remarks
The shape the reference-data endpoints want; see ReadZstdJsonLinesAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) for why nothing in M3 calls it.
The buffering half of a pair — SendZstdJsonLinesStreamAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken) is the streaming half, which yields rows as they decompress instead of returning a list.
Exceptions
- DatabentoApiException
The API answered with a non-success status.
SendZstdJsonLinesStreamAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken)
Sends a request and streams its body's rows as they decompress — SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken) and ReadZstdJsonLinesStreamAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) composed, with the response disposed when the enumeration ends.
public IAsyncEnumerable<T> SendZstdJsonLinesStreamAsync<T>(HttpMethod method, string slug, IEnumerable<KeyValuePair<string, string>>? parameters, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
Parameters
methodHttpMethodThe HTTP method.
slugstringThe API slug, without the version prefix.
parametersIEnumerable<KeyValuePair<string, string>>The request parameters, or null for none.
typeInfoJsonTypeInfo<T>The source-generated metadata for
T.cancellationTokenCancellationTokenCancels the request and the enumeration.
Returns
- IAsyncEnumerable<T>
One element per non-blank line, in the order they arrived.
Type Parameters
TThe type each line deserializes into.
Remarks
The streaming half of a pair; SendZstdJsonLinesAsync<T>(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, JsonTypeInfo<T>, CancellationToken) is the buffering half. Rows come out in the order they arrived and this method sorts nothing — see ReadZstdJsonLinesStreamAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken), which is where that decision is argued in full.
Nothing is sent until the enumeration starts, and the response lives exactly as long as
the enumerator. The request is issued from inside the iterator, so the
using that owns the HttpResponseMessage is unwound by
IAsyncEnumerator.DisposeAsync — which is what await foreach does on its way
out, whether the loop ran to the end, hit an exception, or break'd after one row.
Hoisting the send out of the iterator, into the validating method above it, would compile
and pass a happy-path test and would leak the socket for every caller who stopped early, so
the tests prove the close from the gateway's side rather than from ours.
Split into a validating method and a private iterator for the reason
ReadZstdJsonLinesStreamAsync<T>(HttpResponseMessage, JsonTypeInfo<T>, CancellationToken) gives: a bad argument should fault at the call
rather than at the first MoveNextAsync — or, for a caller who never enumerates, not
at all. The checks duplicate SendAsync(HttpMethod, string, IEnumerable<KeyValuePair<string, string>>?, string?, CancellationToken)'s own deliberately, because inside an
iterator its checks no longer run when the caller makes the call.
Exceptions
- ArgumentNullException
An argument is null.
- ArgumentException
slugis null or empty.- DatabentoApiException
The API answered with a non-success status.
- OperationCanceledException
cancellationTokenwas cancelled.