fix: deterministic error handling for canceled map shares#163
fix: deterministic error handling for canceled map shares#163gmaclennan merged 1 commit intomainfrom
Conversation
When a sender cancels a map share, receiver actions now produce clear, predictable errors with a `code` property instead of leaking internal server errors or ambiguous status transition messages. Behavior changes: - All receiver actions (download, decline, abort) check for 'canceled' status before proceeding. If the share is already known to be canceled, they throw MapShareCanceledError (code: 'MAP_SHARE_CANCELED'). - decline() no longer uses an optimistic update. The share status stays 'pending' until the server confirms, then transitions to 'declined'. This avoids an irrecoverable state when the server rejects the decline (e.g. because the sender already canceled). If the server reports MAP_SHARE_CANCELED, the share transitions to 'canceled' (not 'error'). - decline() now calls .json() on the POST response so that server error codes are properly surfaced through the HTTPError chain. - assertValidStatusTransition() throws InvalidStatusTransitionError (code: 'INVALID_STATUS_TRANSITION') instead of a plain Error. New exports: - MapShareCanceledError — thrown for actions on canceled shares - InvalidStatusTransitionError — thrown for invalid status transitions - MapShareErrorCode — const object of all error codes a consumer may encounter, documented by sender/receiver role - getErrorCode() — safely extracts .code from an unknown error Requires @comapeo/map-server >=1.1.0 for the MAP_SHARE_CANCELED error code on the decline path (previously returned DECLINE_SHARE_NOT_PENDING).
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
achou11
left a comment
There was a problem hiding this comment.
Provided some thoughts on improvements to how the error types are surfaced from the relevant hooks.
| * @throws {MapShareCanceledError} If the share is already known to be canceled | ||
| * (i.e. `status` is `'canceled'` in the store, e.g. after a previous | ||
| * download attempt discovered the cancellation). | ||
| * @throws {InvalidStatusTransitionError} If the share is not in a valid state |
There was a problem hiding this comment.
While having these is helpful for docs purposes, it's not useful when it comes to inferring types. This is basically claiming that the hook throws these errors when it's actually the mutation methods that need to be annotated with these.
Think it would be more appropriate to update the hook implementation to specify these as type param inputs for the useMutation:
export function useDownloadReceivedMapShare() {
const { download } = useReceivedMapSharesActions()
return filterMutationResult(
useMutation<
void,
MapShareCanceledError | InvalidStatusTransitionError | Error,
DownloadMapShareOptions
>({
...baseMutationOptions(),
mutationFn: async (options: DownloadMapShareOptions) => {
return download(options)
},
}),
)
}That way the error callback/catch handler will be decorated and can be narrowed:
Doing this might make the getErrorCode export a little less necessary too.
There was a problem hiding this comment.
Probably makes more sense for these @throws annotations to be moved to relevant methods in the maps shares store implementation, although an explicit errors annotation for the useMutation usage will still be needed.
There was a problem hiding this comment.
Doing this might make the getErrorCode export a little less necessary too.
Ah, I guess if using mutateAsync() then it might still be helpful to have this helper, as the caught error in the try/catch still uses a broad any/unknown type.
Guess that's why using mutate() over mutateAsync() can be preferable 😄
| * @throws {MapShareCanceledError} If the share is already known to be | ||
| * canceled, or if the server reports that the sender canceled the share | ||
| * while the decline was in flight. In both cases `share.status` will be | ||
| * `'canceled'`. | ||
| * @throws {InvalidStatusTransitionError} If the share is not in |
There was a problem hiding this comment.
Same comment about passing these as explicit type param inputs for the useMutation, and moving these annotations to the map shares store.
| * @throws {MapShareCanceledError} If the share is already known to be canceled. | ||
| * @throws {InvalidStatusTransitionError} If the share is not in |
There was a problem hiding this comment.
Same comment about using these in useMutation type param inputs and moving these annotations to the map shares store.
| export function getErrorCode(error: unknown): string | undefined { | ||
| if ( | ||
| error instanceof Error && | ||
| 'code' in error && | ||
| typeof error.code === 'string' | ||
| ) { | ||
| return error.code | ||
| } | ||
| return undefined | ||
| } |
There was a problem hiding this comment.
depending on thoughts on my other suggestions, it may not be necessary to export a helper for this, as the check becomes relatively trivial to do in consuming app code.
There was a problem hiding this comment.
my original remark is potentially less convincing based on my follow-up in #163 (comment).
|
@achou11 I started adding the error type annotations, but then rolled them back. Because errors aren't typed by Typescript, adding the types is really just guidance, and I think misleading. A mutation function could easily throw a different error due to a bug, and the types don't reflect this. On reflection I think it's better not to type the errors on these mutation functions, because it could result in a runtime error relying on the errors to be a specific type, when there is no guarantee that they will be. Regarding documenting the hook rather than the mutate function, there's not really a clean way to do that with tanstack query. It involves destructuring the return value of |
That's why I still included |
Summary
When a sender cancels a map share, receiver-side hooks now produce clear, predictable errors that are easy to handle in app code. Previously, errors leaked internal server codes (
DECLINE_SHARE_NOT_PENDING) or threw plainErrorobjects with no.code, making it difficult to distinguish "the sender canceled" from other failures.Upstream: @comapeo/map-server changes (v1.1.0)
This fix required a change to
@comapeo/map-serverto return a specific error when the receiver tries to decline a share that the sender has already canceled.Before (v1.0.x):
MapShare.decline()threwDECLINE_SHARE_NOT_PENDINGfor any non-pending status — the same error whether the share was canceled, completed, downloading, or in any other state. The client had no way to distinguish "canceled by sender" from other failures.After (v1.1.0):
MapShare.decline()now checks for'canceled'status specifically and throws the newMAP_SHARE_CANCELEDerror (HTTP 409) before falling through to the genericDECLINE_SHARE_NOT_PENDINGcheck:The download path was already correct —
DOWNLOAD_SHARE_CANCELEDis handled internally byDownloadRequest, which sets the share status to'canceled'and delivers it to the client via EventSource. No change was needed there.What changed for consumers
Handling canceled shares
All receiver actions now behave consistently when a share has been canceled by the sender:
useDownloadReceivedMapShare— The mutation resolves (the download starts), but the share status ends up as'canceled'rather than'completed'. This is the mechanism by which the receiver discovers cancellation. Checkshare.statusafter the download settles.useDeclineReceivedMapShare— The mutation throws withcode: 'MAP_SHARE_CANCELED'. The share status transitions to'canceled'(not'error'). This is true whether the store already knew about the cancellation or the server reported it during the decline request.useAbortReceivedMapShareDownload— The mutation throws withcode: 'MAP_SHARE_CANCELED'if the share is known to be canceled.Decline is no longer optimistic
useDeclineReceivedMapSharepreviously updated the share status to'declined'immediately before the server confirmed. This caused an irrecoverable state when the server rejected the decline (the share was stuck in'declined'locally but the server disagreed). Now the share stays in'pending'until the server confirms, then transitions to'declined'. Usemutation.isPendingto show a loading state in the UI.New exports for error handling
All errors now carry a
.codestring property. Since mutation errors are typed asError | null, use the newgetErrorCode()helper to safely extract the code:Or in a try/catch:
Error code reference
Receiver mutation errors (thrown by hooks — check via
getErrorCode(mutation.error)):MAP_SHARE_CANCELEDINVALID_STATUS_TRANSITIONMAP_SHARE_NOT_FOUNDshareIdexistsDOWNLOAD_NOT_FOUNDReceiver share state errors (on
share.error.codewhenshare.status === 'error'):DOWNLOAD_ERRORDECLINE_CANNOT_CONNECTSender mutation errors (thrown by hooks):
INVALID_STATUS_TRANSITIONMAP_SHARE_NOT_FOUNDshareIdSender share state errors (on
share.error.codewhenshare.status === 'error'):CANCEL_SHARE_NOT_CANCELABLECommon:
UNKNOWN_ERRORBreaking changes
useDeclineReceivedMapShareno longer optimistically updates share status. Apps relying on the share immediately showingstatus: 'declined'before the server responds should usemutation.isPendinginstead.@comapeo/map-server>= 1.1.0.Test plan
'canceled'statusMAP_SHARE_CANCELED, share status is'canceled''canceled'→ throwMAP_SHARE_CANCELED.codeassertions