Repository navigation
RG-T152 Mapbox Nav Fallback #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ namespace Resgrid.Services | |
| /// <summary> | ||
| /// Routes, matrices and stop-order optimization through the install's routing provider | ||
| /// (maplibre-mapping-routing-navigation-plan.md §6.1): the provider MappingConfig.RoutingProvider names, then | ||
| /// RoutingFallbackProvider, then the straight-line estimate. Each call is bounded by RoutingTimeoutMs; results are | ||
| /// RoutingFallbackProvider, then Mapbox when Valhalla is either of those, then the straight-line estimate. Each call is bounded by RoutingTimeoutMs; results are | ||
| /// cached in Redis by provider, profile, vehicle and coordinates (30 days, 5 minutes when traffic-aware); a provider | ||
| /// that just failed is skipped for a short back-off so a broken key or server does not slow every request. | ||
| /// A department with its own routing account (setting 125) has it tried first, then the system chain. Back-off, cache | ||
|
|
@@ -179,8 +179,8 @@ private IRoutingProvider Find(string name) | |
| } | ||
|
|
||
| /// <summary> | ||
| /// The department's own account (if any), then primary, fallback, estimate — skipping duplicates, unconfigured | ||
| /// providers and ones in back-off. | ||
| /// The department's own account (if any), then primary, fallback, Mapbox when Valhalla is one of those, estimate — | ||
| /// skipping duplicates, unconfigured providers and ones in back-off. | ||
| /// </summary> | ||
| private async Task<List<Link>> ChainAsync(int departmentId) | ||
| { | ||
|
|
@@ -200,19 +200,65 @@ void Add(Link link) | |
|
|
||
| Link System(IRoutingProvider provider) => provider == null ? null : new Link { Provider = provider, Key = provider.Name }; | ||
|
|
||
| Add(await DepartmentLinkAsync(departmentId)); | ||
| Add(System(Find(MappingConfig.RoutingProvider))); | ||
| Add(System(Find(MappingConfig.RoutingFallbackProvider))); | ||
| // The estimate always answers last, even when RoutingProvider or RoutingFallbackProvider names it. | ||
| Link Routed(string name) | ||
| { | ||
| var provider = Find(name); | ||
| return provider == null || provider.Name == RoutingProviderNames.Estimate ? null : System(provider); | ||
| } | ||
|
|
||
| var department = await DepartmentLinkAsync(departmentId); | ||
| var primary = Routed(MappingConfig.RoutingProvider); | ||
| var fallback = Routed(MappingConfig.RoutingFallbackProvider); | ||
|
|
||
| Add(department); | ||
| Add(primary); | ||
| Add(fallback); | ||
|
|
||
| // Valhalla (a hosted plan's per-second and daily limits, a server that is down, a point off its map) hands over to | ||
| // the install's Mapbox before the straight-line estimate, whatever RoutingFallbackProvider says. Decided on what is | ||
| // configured, not on what is in the chain: while Valhalla sits out its back-off Mapbox must still answer. | ||
| if (UsesValhalla(department, primary, fallback)) | ||
| Add(System(Find(RoutingProviderNames.Mapbox))); | ||
|
|
||
| Add(System(Estimate)); | ||
| return chain; | ||
| } | ||
|
|
||
| private static bool UsesValhalla(params Link[] links) => | ||
| links.Any(l => l?.Provider != null && l.Provider.Name == RoutingProviderNames.Valhalla && l.Provider.IsConfigured); | ||
|
|
||
| /// <summary>What answers after <paramref name="link"/> in this request's chain, for the log.</summary> | ||
| private static string Next(IReadOnlyList<Link> chain, Link link) | ||
| { | ||
| var rest = (chain ?? new List<Link>()).SkipWhile(l => !ReferenceEquals(l, link)).Skip(1) | ||
| .Select(l => l.Provider.Name == RoutingProviderNames.Estimate ? "the straight-line estimate" : l.DepartmentAccount ? $"{l.Provider.Name} (department account)" : l.Provider.Name) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NullReferenceException can occur when the link or its provider is null before accessing Kody rule violation: Add null checks to prevent NullReferenceException .Select(l => l?.Provider?.Name == RoutingProviderNames.Estimate ? "the straight-line estimate" : l?.DepartmentAccount == true ? $"{l.Provider?.Name ?? "unknown provider"} (department account)" : l?.Provider?.Name ?? "unknown provider")Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| .ToList(); | ||
|
Comment on lines
+234
to
+236
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Readability suffers because the multi-step LINQ chain combines filtering, provider-name formatting, and materialization, with the provider-name projection nested in the chain. Split it into the named Kody rule violation: Limit Lengthy LINQ Chains var remainingLinks = (chain ?? new List<Link>()).SkipWhile(l => !ReferenceEquals(l, link)).Skip(1);
var providerNames = remainingLinks.Select(GetProviderDescription);
var rest = providerNames.ToList();Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
|
|
||
| return rest.Count == 0 ? "no other provider" : string.Join(" then ", rest); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Valhalla without a usable system Mapbox goes straight to the estimate when it fails: say how to close that gap. | ||
| /// </summary> | ||
| private string MapboxGap(Link link) | ||
| { | ||
| if (link?.Provider?.Name != RoutingProviderNames.Valhalla) | ||
| return string.Empty; | ||
|
|
||
| var mapbox = Find(RoutingProviderNames.Mapbox); | ||
| return mapbox != null && mapbox.IsConfigured | ||
| ? string.Empty | ||
| : " Set MappingConfig.MapboxRoutingAccessToken (or a Mapbox WebsiteMapboxAccessToken) so Mapbox answers before the straight-line estimate."; | ||
| } | ||
|
|
||
| public async Task<RouteResult> GetRouteAsync(RouteRequest request, CancellationToken cancellationToken = default) | ||
| { | ||
| if (request?.Waypoints == null || request.Waypoints.Count < 2) | ||
| return new RouteResult { Error = "A route needs at least two points" }; | ||
|
|
||
| foreach (var link in await ChainAsync(request.DepartmentId)) | ||
| var chain = await ChainAsync(request.DepartmentId); | ||
| foreach (var link in chain) | ||
| { | ||
| var provider = link.Provider; | ||
| var trafficAware = IsTrafficAware(provider, request.Profile); | ||
|
|
@@ -267,9 +313,9 @@ public async Task<RouteResult> GetRouteAsync(RouteRequest request, CancellationT | |
| } | ||
|
|
||
| if (result?.RequestRejected == true) | ||
| RecordRejection(link, result.Error); | ||
| RecordRejection(chain, link, result.Error); | ||
| else | ||
| RecordFailure(link, result?.Error); | ||
| RecordFailure(chain, link, result?.Error); | ||
| } | ||
|
|
||
| return new RouteResult { Error = "No routing provider returned a route" }; | ||
|
|
@@ -305,7 +351,8 @@ public async Task<RouteMatrixResult> GetMatrixAsync(RouteMatrixRequest request, | |
| var pending = Enumerable.Range(0, origins).ToList(); | ||
| string provided = null; | ||
|
|
||
| foreach (var link in await ChainAsync(request.DepartmentId)) | ||
| var chain = await ChainAsync(request.DepartmentId); | ||
| foreach (var link in chain) | ||
| { | ||
| var provider = link.Provider; | ||
| var trafficAware = IsTrafficAware(provider, request.Profile); | ||
|
|
@@ -378,9 +425,9 @@ public async Task<RouteMatrixResult> GetMatrixAsync(RouteMatrixRequest request, | |
| // One origin outside the provider's coverage fails its whole batch; the next provider takes the | ||
| // remaining origins for this request only. | ||
| if (result?.RequestRejected == true) | ||
| RecordRejection(link, result.Error); | ||
| RecordRejection(chain, link, result.Error); | ||
| else | ||
| RecordFailure(link, result?.Error); | ||
| RecordFailure(chain, link, result?.Error); | ||
| failed = true; | ||
| break; | ||
| } | ||
|
|
@@ -419,7 +466,8 @@ public async Task<RouteOptimizationResult> OptimizeAsync(RouteOptimizationReques | |
|
|
||
| // Each provider in the chain that can optimize this many stops gets its turn (a department account that can't, or | ||
| // that fails, hands over to the install's providers, as routes do); the matrix heuristic answers when none does. | ||
| foreach (var link in (await ChainAsync(request.DepartmentId)).Where(l => l.Provider.Name != RoutingProviderNames.Estimate)) | ||
| var chain = await ChainAsync(request.DepartmentId); | ||
| foreach (var link in chain.Where(l => l.Provider.Name != RoutingProviderNames.Estimate)) | ||
| { | ||
| var provider = link.Provider; | ||
| if (!provider.Capabilities.Optimize || request.Waypoints.Count > provider.Capabilities.MaxOptimizeWaypoints) | ||
|
|
@@ -444,7 +492,7 @@ public async Task<RouteOptimizationResult> OptimizeAsync(RouteOptimizationReques | |
| catch (Exception ex) | ||
| { | ||
| await RecordUsageAsync(link, RoutingUsageOperation.Optimize, false, false, false); | ||
| RecordFailure(link, ex.Message); | ||
| RecordFailure(chain, link, ex.Message); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -494,7 +542,7 @@ private static bool IsTrafficAware(IRoutingProvider provider, RoutingProfile pro | |
| return profile == RoutingProfile.DrivingTraffic && MappingConfig.RoutingTrafficAware && provider.Capabilities.Traffic; | ||
| } | ||
|
|
||
| private static void RecordFailure(Link link, string error) | ||
| private void RecordFailure(IReadOnlyList<Link> chain, Link link, string error) | ||
| { | ||
| var provider = link?.Provider; | ||
| if (provider == null || provider.Name == RoutingProviderNames.Estimate) | ||
|
|
@@ -515,14 +563,14 @@ private static void RecordFailure(Link link, string error) | |
| return; | ||
| } | ||
|
|
||
| Logging.LogError($"Routing provider '{provider.Name}' failed: {error}. Check MappingConfig.RoutingProvider and the provider's settings ({SettingsFor(provider.Name)}); falling back to {MappingConfig.RoutingFallbackProvider} then the straight-line estimate."); | ||
| Logging.LogError($"Routing provider '{provider.Name}' failed: {error}. Check MappingConfig.RoutingProvider and the provider's settings ({SettingsFor(provider.Name)}); falling back to {Next(chain, link)}.{MapboxGap(link)}"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The interpolated error message combines the operation, provider, error, fallback provider, and department context into unstructured text, limiting field-based querying. Emit structured fields for the operation, provider, error, fallback provider, and relevant department identifier. Kody rule violation: Include error context in structured logs Logging.LogError("Routing provider failed", new { Operation = "Route", Provider = provider.Name, Error = error, NextProvider = Next(chain, link), DepartmentId = link.DepartmentAccount ? link.DepartmentId : (int?)null });Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| } | ||
|
|
||
| /// <summary> | ||
| /// The provider is up but cannot route this request (outside its coverage, no road near a point, over its limits): | ||
| /// the next provider answers it and the provider stays in use for everything else. Logged at most every 10 minutes. | ||
| /// </summary> | ||
| private static void RecordRejection(Link link, string error) | ||
| private void RecordRejection(IReadOnlyList<Link> chain, Link link, string error) | ||
| { | ||
| var provider = link?.Provider; | ||
| if (provider == null || provider.Name == RoutingProviderNames.Estimate) | ||
|
|
@@ -534,7 +582,7 @@ private static void RecordRejection(Link link, string error) | |
| return; | ||
|
|
||
| LastErrorLogged[key] = now; | ||
| Logging.LogInfo($"Routing provider '{provider.Name}' could not route a request ({error}); {MappingConfig.RoutingFallbackProvider} answered it instead. The provider stays in use for other requests."); | ||
| Logging.LogInfo($"Routing provider '{provider.Name}' could not route a request ({error}); {Next(chain, link)} answered it instead. The provider stays in use for other requests.{MapboxGap(link)}"); | ||
| } | ||
|
|
||
| private static string SettingsFor(string provider) | ||
|
|
@@ -544,7 +592,7 @@ private static string SettingsFor(string provider) | |
| case RoutingProviderNames.Valhalla: | ||
| return "MappingConfig.ValhallaUrl, MappingConfig.ValhallaApiKey, MappingConfig.ValhallaAuthScheme"; | ||
| case RoutingProviderNames.Mapbox: | ||
| return "MappingConfig.MapboxRoutingAccessToken"; | ||
| return "MappingConfig.MapboxRoutingAccessToken, else MappingConfig.WebsiteMapboxAccessToken / WebsiteMapboxKey sent with SystemBehaviorConfig.ResgridBaseUrl as Referer"; | ||
| case RoutingProviderNames.Google: | ||
| return "MappingConfig.GoogleMapsApiKey, MappingConfig.GoogleRoutingApi"; | ||
| case RoutingProviderNames.Osrm: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| using System.Globalization; | ||
| using System.Linq; | ||
| using System.Net; | ||
| using System.Net.Http; | ||
| using System.Text; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
@@ -14,8 +15,9 @@ namespace Resgrid.Providers.GeoLocationProvider.Routing | |
| { | ||
| /// <summary> | ||
| /// Mapbox Directions v5, Matrix v1 and Optimization v1 (plan §6.1) with the server-side token | ||
| /// (MappingConfig.MapboxRoutingAccessToken). Live traffic and incidents on driving-traffic. Applies vehicle height, | ||
| /// width and weight (max_height/max_width/max_weight); Mapbox has no length, axle or hazmat restrictions. | ||
| /// (MappingConfig.MapboxRoutingAccessToken), else the web app's Mapbox map token. Live traffic and incidents on | ||
| /// driving-traffic. Applies vehicle height, width and weight (max_height/max_width/max_weight); Mapbox has no length, | ||
| /// axle or hazmat restrictions. | ||
| /// </summary> | ||
| public class MapboxRoutingProvider : IRoutingProvider | ||
| { | ||
|
|
@@ -39,7 +41,39 @@ public IRoutingProvider WithCredentials(RoutingProviderCredentials credentials) | |
| return credentials == null ? null : new MapboxRoutingProvider(credentials); | ||
| } | ||
|
|
||
| private string AccessToken => _department != null ? _department.ApiKey : MappingConfig.MapboxRoutingAccessToken; | ||
| private string AccessToken => _department != null ? _department.ApiKey : InstallToken().Token; | ||
|
|
||
| /// <summary> | ||
| /// The install's token for Directions, Matrix and Optimization: MapboxRoutingAccessToken, else the web app's Mapbox | ||
| /// map token (WebsiteMapboxAccessToken, then WebsiteMapboxKey), which those APIs accept as well — an install with | ||
| /// Mapbox maps routes on Mapbox without a separate routing token. Only a Mapbox token (pk. or sk.) is borrowed from the | ||
| /// web settings, since WebsiteMapboxKey may hold another tile service's key. The web map token is public and usually | ||
| /// URL-restricted to the web app, which Mapbox checks against the Referer, so it comes with the install's own web | ||
| /// address to send as one; the routing token comes with none. Token null: no Mapbox token at all. | ||
| /// </summary> | ||
| public static (string Token, Uri Referer) InstallToken() | ||
| { | ||
| var routing = MappingConfig.MapboxRoutingAccessToken?.Trim(); | ||
| if (!string.IsNullOrEmpty(routing)) | ||
| return (routing, null); | ||
|
|
||
| var website = new[] { MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey } | ||
| .Select(t => t?.Trim()) | ||
| .FirstOrDefault(t => !string.IsNullOrEmpty(t) && | ||
| (t.StartsWith("pk.", StringComparison.Ordinal) || t.StartsWith("sk.", StringComparison.Ordinal))); | ||
| if (website == null) | ||
| return (null, null); | ||
|
|
||
| Uri.TryCreate(SystemBehaviorConfig.ResgridBaseUrl?.Trim().TrimEnd('/') + "/", UriKind.Absolute, out var site); | ||
| return (website, site); | ||
| } | ||
|
|
||
| /// <summary>Sets the Referer the install's web map token needs; nothing for a routing or department token.</summary> | ||
| private Action<HttpRequestMessage> Referer() | ||
| { | ||
| var referer = _department == null ? InstallToken().Referer : null; | ||
| return referer == null ? null : request => request.Headers.Referrer = referer; | ||
| } | ||
|
|
||
| public string Name => RoutingProviderNames.Mapbox; | ||
|
|
||
|
|
@@ -89,7 +123,7 @@ public async Task<RouteResult> GetRouteAsync(RouteRequest request, CancellationT | |
| AppendStopIndexes(query, request.Waypoints); | ||
|
|
||
| var url = $"{BaseUrl}/directions/v5/mapbox/{profile}/{RoutingHttp.LonLatPath(request.Waypoints)}{query}"; | ||
| var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken); | ||
| var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unhandled task rejection can escape Kody rule violation: Handle async operations with proper error handling try
{
var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer());
}
catch (Exception ex)
{
// Log and map the routing failure appropriately.
throw;
}Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unhandled external HTTP failures from Kody rule violation: Add try-catch blocks for external calls try
{
var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer());
}
catch (Exception ex)
{
// Add operation context and map the external failure.
throw;
}Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
|
|
||
| using (document) | ||
| { | ||
|
|
@@ -129,7 +163,7 @@ public async Task<RouteMatrixResult> GetMatrixAsync(RouteMatrixRequest request, | |
| query.Append("&sources=").Append(sources).Append("&destinations=").Append(destinations); | ||
|
|
||
| var url = $"{BaseUrl}/directions-matrix/v1/mapbox/{profile}/{RoutingHttp.LonLatPath(coordinates)}{query}"; | ||
| var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken); | ||
| var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer()); | ||
|
|
||
| using (document) | ||
| { | ||
|
|
@@ -164,7 +198,7 @@ public async Task<RouteOptimizationResult> OptimizeAsync(RouteOptimizationReques | |
| query.Append("&source=first&destination=last&roundtrip=false&overview=false"); | ||
|
|
||
| var url = $"{BaseUrl}/optimized-trips/v1/mapbox/{profile}/{RoutingHttp.LonLatPath(request.Waypoints)}{query}"; | ||
| var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken); | ||
| var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer()); | ||
|
|
||
| using (document) | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mapbox matrix fallback fails for 25 or more destinations.
When Valhalla fails, the chain calls Mapbox next. The matrix request still contains every destination, but Mapbox rejects any matrix with 25 or more destinations. For driving-traffic, the threshold is 10.
MapboxRoutingProvider.GetMatrixAsynconly chunks origins, so the rejection cannot be avoided. The request then falls through to the straight-line estimate, and the road-based times this fallback is meant to provide are not returned.Partition the destinations in
MapboxRoutingProvider.GetMatrixAsyncso each request stays within the Mapbox coordinate limit. Write each chunk's results into the matching destination columns of the returned matrix.