Skip to content
Open
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
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,16 @@ if(APPLE)
endif()

source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES})

# Unit tests: on by default only when UrlLib is the top-level project being built for a
# desktop host, so consumers that FetchContent UrlLib are unaffected.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR AND NOT IOS AND NOT ANDROID)
option(URLLIB_TESTS "Build the UrlLib unit tests." ON)
else()
option(URLLIB_TESTS "Build the UrlLib unit tests." OFF)
endif()

if(URLLIB_TESTS)
enable_testing()
add_subdirectory(Tests)
endif()
16 changes: 16 additions & 0 deletions Include/UrlLib/UrlLib.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include <cstdint>
#include <memory>
#include <optional>
#include <string>
Expand Down Expand Up @@ -63,6 +64,21 @@ namespace UrlLib

std::string_view StatusText() const;

// Transport-level error reporting. All three return empty/zero when the request did
// not fail at the transport layer (note that an HTTP error status like 404 is NOT a
// transport failure). ErrorString() is normalized for log-pipeline filtering:
// "<domain>:<symbol>(<code>): <detail>"
// where <domain> and <symbol> are stable ASCII tokens (e.g. "curl",
// "CURLE_COULDNT_CONNECT") and <detail> is the platform's human-readable message.
std::string_view ErrorString() const;

// The stable, platform-specific symbolic name of the failure, e.g.
// "CURLE_COULDNT_RESOLVE_HOST" or "NSURLErrorTimedOut".
std::string_view ErrorSymbol() const;

// The raw numeric platform error code (CURLcode, NSError code, etc.).
int32_t ErrorCode() const;

std::string_view ResponseUrl() const;

std::string_view ResponseString() const;
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ UrlLib is a cross-platform C++ library that utilizes platform-specific implement
for URL-related functionality. Although it was created as a subcomponent of
[JsRuntimeHost](https://github.com/BabylonJS/JsRuntimeHost) for its polyfills, it may also be used standalone.

## Error reporting

A request that fails at the transport layer (DNS failure, connection refused, TLS failure,
missing local file, ...) completes with a `StatusCode()` of 0. To make those failures
diagnosable from logs and crash reports, `UrlRequest` additionally exposes:

* `ErrorString()` — the full normalized error, shaped for log-pipeline filtering:
`"<domain>:<symbol>(<code>): <detail>"`
* `ErrorSymbol()` — the stable symbolic token alone (e.g. `CURLE_COULDNT_RESOLVE_HOST`,
`NSURLErrorTimedOut`)
* `ErrorCode()` — the raw numeric platform code (CURLcode, NSError code, ...)

All three are empty/zero when the request did not fail at the transport layer; an HTTP
error status such as 404 is **not** a transport failure.

Examples:

```
curl:CURLE_COULDNT_CONNECT(7): Failed to connect to 127.0.0.1 port 47651 after 0 ms: Couldn't connect to server
curl:CURLE_FILE_COULDNT_READ_FILE(37): Couldn't open file /tmp/missing.bin
nsurl:NSURLErrorCannotConnectToHost(-1004): Could not connect to the server.
nsurl:NSURLErrorServerCertificateUntrusted(-1202): The certificate for this server is invalid. ...
urllib:AppResourceNotFound(0): no bundled resource for 'app:///missing.js'
```

On Apple platforms, when the `NSError` carries an underlying-error chain with different
codes (e.g. a POSIX-level failure), each distinct level is appended as
`<- <domain>(<code>): <message>`.

The `<domain>` and `<symbol>` tokens are stable ASCII identifiers, so observability
queries can filter on exact substrings (e.g. Splunk `"curl:CURLE_COULDNT_RESOLVE_HOST"`
or `"nsurl:NSURLErrorTimedOut"`). The `<detail>` portion is the platform's human-readable
message — it may be OS-localized on Apple platforms and includes request specifics like
host, port, and path where the platform provides them.

Platform support: the Apple (`NSURLSession`) and Linux (`libcurl`) backends populate
these accessors today; the Windows and Android backends currently always report
empty/zero (contributions welcome — the plumbing in `UrlRequest_Base.h` is shared).

## Contributing

Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of conduct, and
Expand Down
93 changes: 89 additions & 4 deletions Source/UrlRequest_Apple.mm
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,50 @@
range.length = 0x7e - range.location + 1;
return [NSCharacterSet characterSetWithRange:range];
}();

// Stable symbolic names for the NSURLErrorDomain codes most likely to be diagnostic in
// the field. Anything else gets a synthesized "NSURLError_<n>" token. (The numeric code
// is always reported alongside, so unmapped codes lose nothing but readability.)
std::string NSURLErrorSymbol(NSInteger code)
{
switch (code)
{
case NSURLErrorUnknown: return "NSURLErrorUnknown";
case NSURLErrorCancelled: return "NSURLErrorCancelled";
case NSURLErrorBadURL: return "NSURLErrorBadURL";
case NSURLErrorTimedOut: return "NSURLErrorTimedOut";
case NSURLErrorUnsupportedURL: return "NSURLErrorUnsupportedURL";
case NSURLErrorCannotFindHost: return "NSURLErrorCannotFindHost";
case NSURLErrorCannotConnectToHost: return "NSURLErrorCannotConnectToHost";
case NSURLErrorNetworkConnectionLost: return "NSURLErrorNetworkConnectionLost";
case NSURLErrorDNSLookupFailed: return "NSURLErrorDNSLookupFailed";
case NSURLErrorHTTPTooManyRedirects: return "NSURLErrorHTTPTooManyRedirects";
case NSURLErrorResourceUnavailable: return "NSURLErrorResourceUnavailable";
case NSURLErrorNotConnectedToInternet: return "NSURLErrorNotConnectedToInternet";
case NSURLErrorRedirectToNonExistentLocation: return "NSURLErrorRedirectToNonExistentLocation";
case NSURLErrorBadServerResponse: return "NSURLErrorBadServerResponse";
case NSURLErrorUserCancelledAuthentication: return "NSURLErrorUserCancelledAuthentication";
case NSURLErrorUserAuthenticationRequired: return "NSURLErrorUserAuthenticationRequired";
case NSURLErrorZeroByteResource: return "NSURLErrorZeroByteResource";
case NSURLErrorCannotDecodeRawData: return "NSURLErrorCannotDecodeRawData";
case NSURLErrorCannotDecodeContentData: return "NSURLErrorCannotDecodeContentData";
case NSURLErrorCannotParseResponse: return "NSURLErrorCannotParseResponse";
case NSURLErrorAppTransportSecurityRequiresSecureConnection: return "NSURLErrorAppTransportSecurityRequiresSecureConnection";
case NSURLErrorFileDoesNotExist: return "NSURLErrorFileDoesNotExist";
case NSURLErrorFileIsDirectory: return "NSURLErrorFileIsDirectory";
case NSURLErrorNoPermissionsToReadFile: return "NSURLErrorNoPermissionsToReadFile";
case NSURLErrorDataLengthExceedsMaximum: return "NSURLErrorDataLengthExceedsMaximum";
case NSURLErrorSecureConnectionFailed: return "NSURLErrorSecureConnectionFailed";
case NSURLErrorServerCertificateHasBadDate: return "NSURLErrorServerCertificateHasBadDate";
case NSURLErrorServerCertificateUntrusted: return "NSURLErrorServerCertificateUntrusted";
case NSURLErrorServerCertificateHasUnknownRoot: return "NSURLErrorServerCertificateHasUnknownRoot";
case NSURLErrorServerCertificateNotYetValid: return "NSURLErrorServerCertificateNotYetValid";
case NSURLErrorClientCertificateRejected: return "NSURLErrorClientCertificateRejected";
case NSURLErrorClientCertificateRequired: return "NSURLErrorClientCertificateRequired";
case NSURLErrorCannotLoadFromNetwork: return "NSURLErrorCannotLoadFromNetwork";
default: return "NSURLError_" + std::to_string(static_cast<long>(code));
}
}
}

namespace UrlLib
Expand All @@ -31,7 +75,7 @@ void Open(UrlMethod method, const std::string& url)
m_url = [NSURL URLWithString:[[NSString stringWithUTF8String:url.data()] stringByAddingPercentEncodingWithAllowedCharacters:URLAllowedCharacterSet]];
if (!m_url || !m_url.scheme)
{
throw std::runtime_error{"URL does not have a valid scheme"};
throw std::runtime_error{"URL does not have a valid scheme: '" + url + "'"};
}
NSString* scheme{m_url.scheme};
if ([scheme isEqual:@"app"])
Expand All @@ -42,7 +86,9 @@ void Open(UrlMethod method, const std::string& url)
// No bundled resource at this path. Don't throw -- let SendAsync's existing
// `if (m_url == nil)` branch complete the task and retain the default status
// code of 0 to indicate a client side error. This matches Win32 / UWP / Unix
// semantics for missing local files.
// semantics for missing local files. Record why so ErrorString() consumers
// can distinguish a missing bundled asset from a network failure.
SetError("urllib", "AppResourceNotFound", 0, "no bundled resource for '" + url + "'");
m_url = nil;
return;
}
Expand Down Expand Up @@ -85,8 +131,47 @@ void Open(UrlMethod method, const std::string& url)
{
if (error != nil)
{
// Complete the task, but retain the default status code of 0 to indicate a client side error.
// TODO: Consider logging or otherwise exposing the error message in some way via: [[error localizedDescription] UTF8String]
// Complete the task, but retain the default status code of 0 to indicate a
// client side error -- and record what actually went wrong so consumers can
// surface it. NSURLErrorDomain codes get stable symbols; other domains pass
// through verbatim. One level of NSUnderlyingErrorKey is appended because
// that is where CFNetwork/POSIX specifics (e.g. "Connection refused") live.
// Note the human-readable detail is localized by the OS; the domain, symbol,
// and numeric code are the stable, filterable parts.
std::string domain{"nsurl"};
std::string symbol;
if ([error.domain isEqualToString:NSURLErrorDomain])
{
symbol = NSURLErrorSymbol(error.code);
}
else
{
domain = [error.domain UTF8String];
symbol = "NSError_" + std::to_string(static_cast<long>(error.code));
}

std::string detail{[[error localizedDescription] UTF8String]};

// Walk the underlying-error chain (bounded), appending only levels that carry
// a different numeric code, so POSIX-level specifics like "Connection refused"
// surface while the kCFErrorDomainCFNetwork echo of the same code (Apple keeps
// NSURL and CFNetwork codes aligned) is skipped.
NSInteger previousCode = error.code;
NSError* underlying = error.userInfo[NSUnderlyingErrorKey];
for (int depth = 0; underlying != nil && depth < 3; ++depth)
{
if (underlying.code != previousCode)
{
detail += " <- ";
detail += [underlying.domain UTF8String];
detail += "(" + std::to_string(static_cast<long>(underlying.code)) + "): ";
detail += [[underlying localizedDescription] UTF8String];
}
previousCode = underlying.code;
underlying = underlying.userInfo[NSUnderlyingErrorKey];
}

SetError(domain, symbol, static_cast<int32_t>(error.code), detail);
taskCompletionSource.complete();
return;
}
Expand Down
49 changes: 49 additions & 0 deletions Source/UrlRequest_Base.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ namespace UrlLib
return ReasonPhraseForStatusCode(static_cast<int>(m_statusCode));
}

std::string_view ErrorString() const
{
return m_errorString;
}

std::string_view ErrorSymbol() const
{
return m_errorSymbol;
}

int32_t ErrorCode() const
{
return m_errorCode;
}

std::string_view ResponseUrl()
{
return m_responseUrl;
Expand Down Expand Up @@ -155,6 +170,34 @@ namespace UrlLib
}
}

// Record a transport-level failure in a normalized, grep-friendly shape:
// "<domain>:<symbol>(<code>): <detail>"
// e.g.
// "curl:CURLE_COULDNT_CONNECT(7): Failed to connect to 127.0.0.1 port 47651 ..."
// "nsurl:NSURLErrorCannotConnectToHost(-1004): Could not connect to the server."
// "urllib:AppResourceNotFound(0): no bundled resource for 'app:///missing.js'"
// `domain` and `symbol` are stable ASCII tokens (no spaces) so observability pipelines
// (Splunk and the like) can filter on exact substrings; `detail` carries the platform's
// human-readable message, including host/port/path specifics where the platform
// provides them. Callers complete SendAsync() normally after recording the error --
// the status code stays 0 (None), preserving the existing contract that transport
// failures surface as a 0 status rather than a faulted task.
void SetError(std::string_view domain, std::string_view symbol, int32_t code, std::string_view detail)
{
m_errorCode = code;
m_errorSymbol = symbol;

m_errorString.clear();
m_errorString.reserve(domain.size() + symbol.size() + detail.size() + 16);
m_errorString.append(domain);
m_errorString.push_back(':');
m_errorString.append(symbol);
m_errorString.push_back('(');
m_errorString.append(std::to_string(code));
m_errorString.append("): ");
m_errorString.append(detail);
}

// Reset the per-request response state that lives in ImplBase. Each platform's `Open()`
// calls this at the start so a single `UrlRequest` can be reused across requests without
// leaking prior status / URL / body / headers. Platform-specific response buffers live in
Expand All @@ -167,13 +210,19 @@ namespace UrlLib
m_responseUrl.clear();
m_responseString.clear();
m_headers.clear();
m_errorCode = 0;
m_errorSymbol.clear();
m_errorString.clear();
}

arcana::cancellation_source m_cancellationSource{};
UrlResponseType m_responseType{UrlResponseType::String};
UrlMethod m_method{UrlMethod::Get};
UrlStatusCode m_statusCode{UrlStatusCode::None};
std::string m_statusText{};
int32_t m_errorCode{};
std::string m_errorSymbol{};
std::string m_errorString{};
std::string m_responseUrl{};
std::string m_responseString{};
std::unordered_map<std::string, std::string> m_headers;
Expand Down
15 changes: 15 additions & 0 deletions Source/UrlRequest_Shared.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ namespace UrlLib
return m_impl->StatusText();
}

std::string_view UrlRequest::ErrorString() const
{
return m_impl->ErrorString();
}

std::string_view UrlRequest::ErrorSymbol() const
{
return m_impl->ErrorSymbol();
}

int32_t UrlRequest::ErrorCode() const
{
return m_impl->ErrorCode();
}

std::string_view UrlRequest::ResponseUrl() const
{
return m_impl->ResponseUrl();
Expand Down
Loading