The error message that gave impossible advice


TL;DR — NSItemProviderErrorDomain -1000 is NSItemProviderItemUnavailableError: “the item is unavailable.” It says nothing about why. In my app, being offline arrived as -1000, with the real cause (NSURLErrorDomain -1009) four levels down the NSUnderlyingError chain. I had mapped -1000 to advice that was impossible to follow when offline. Decide what to tell the user from the root of the error chain, match on the domain rather than a list of codes, and when you can’t establish a cause, don’t name one.

What was running

VertiCut imports a video through PHPickerViewController. The picker hands back an NSItemProvider, and loadFileRepresentation gives you a file. If the video lives in iCloud and isn’t on the device yet, that call is also what downloads it.

Sometimes it fails. The app needs to tell the user something useful when it does.

The message I shipped to testing

I’d seen reports of NSItemProviderErrorDomain -1000 on videos saved into the photo library from other apps, with a note that playing the video once in Photos often fixed it. So I gave -1000 its own case, with a message along these lines:

This can happen with videos saved from other apps. Try playing it once in the Photos app, then choose it again.

It names a cause and gives the user something to do, which is what a good error message is supposed to do.

What a real device showed

I put a phone in airplane mode and picked a video that was still in iCloud. The app showed that same message. Here is the full error chain it had received:

NSItemProviderErrorDomain#-1000   Cannot load representation of type …
  ← NSCocoaErrorDomain#4101       Couldn't communicate with a helper application.
  ← PHAssetExportRequestErrorDomain#2
  ← NSURLErrorDomain#-1009        The Internet connection appears to be offline.

The user was offline. The app told them to go play the video in Photos. The video wasn’t downloaded, so it wouldn’t play in Photos either. The advice was impossible to carry out.

Advice that can’t be followed is slightly worse than saying nothing.

How I got it wrong

Two things combined.

I read the code’s name wrong. A comment in my code called -1000 NSItemProviderUnknownError. That constant is -1. The real name for -1000 is NSItemProviderItemUnavailableError. “Unknown” sounds like a narrow, odd case that deserves special handling. “Item unavailable” is obviously a catch-all. The wrong name made branching on the top-level code look safe.

I only looked at the top of the chain. The top-level error is routinely the least informative one. It’s the outermost wrapper describing what the caller couldn’t do, not why. The middle frames didn’t help either: the Cocoa 4101 and PHAssetExportRequestErrorDomain frames in my offline capture were identical to the ones in the “saved from another app” reports. The only frame that told the cases apart was the root.

I only found this because I had already added code that unwraps the whole chain for diagnostics. With logging that prints just the top-level error, the message on screen would have looked fine and there would have been nothing to check it against.

The fix: decide from the root, match on the domain

Walk the whole chain, then classify on what’s at the bottom.

/// Every error in the chain, outermost first.
func chain(of error: Error) -> [NSError] {
    var result: [NSError] = []
    var queue = [error as NSError]
    while let e = queue.first {
        queue.removeFirst()
        result.append(e)
        if let u = e.userInfo[NSUnderlyingErrorKey] as? NSError { queue.append(u) }
        if let m = e.userInfo[NSMultipleUnderlyingErrorsKey] as? [NSError] { queue.append(contentsOf: m) }
    }
    return result
}

let isNetwork = chain(of: error).contains { $0.domain == NSURLErrorDomain }

(That’s a sketch of the shape, not my exact code.)

Two choices in there matter.

Match the domain, not a list of codes. If NSURLErrorDomain appears anywhere in the chain, the user gets the network message. I don’t list -1009, -1001, -1005 and so on. Listing codes means inventing a boundary I never measured: I’d be claiming to know which network errors count.

Test it with the real chain. The test builds the exact nested error captured on the device and asserts the classification. Reverting to top-level-only classification makes three tests fail.

The second mistake: the leftover branch

After splitting out the network case, the remaining -1000 branch still said “this can happen with videos saved from other apps.”

So I checked whether I could actually establish that. I couldn’t:

What I looked at What it told me
-1000 itself “Item unavailable.” Silent about origin.
The middle frames Identical between the reported cases and my offline capture.
Asset metadata Not visible by design. The picker is configured without the photo library, and asking for asset identifiers would need library permission. Not reading the library is the basis of the app’s privacy claim.
registeredTypeIdentifiers Correlates with container format, not with which app saved the file. Using it would mean inventing a signal.

There was no evidence in the error for “saved from another app.” If I kept that sentence, every unexplained failure would be told a specific cause I had no way to confirm.

So the remaining branch now names no cause. It only says things the user can certainly do: try again, choose a different video, or tap “Copy Details” and send it to me.

If you can’t establish a cause, don’t state one. A specific-sounding message is only better than a vague one when the specifics are true.

The same shape, again: cancellation

While testing slow networks, I hit a related case. After my timeout cancelled the download, the provider answered late with:

NSItemProviderErrorDomain#3072

3072 is NSUserCancelledError. The provider was saying “cancelled” — but in its own domain, not NSCocoaErrorDomain. My cancellation check only looked for 3072 in the Cocoa domain, so it didn’t recognise this as a cancellation.

In that run it did no harm, because the timeout had already ended the operation and the late answer was discarded. But the risk is real: if the user taps Cancel and the provider’s 3072 wins the race, they see an error message for something they did on purpose.

It’s the same mistake as -1000, in the other direction. I had assumed “this fact will arrive in this domain.” Don’t pin a meaning to one narrow domain-and-code pair.

Also: localizedDescription identifies nothing

Don’t branch on, or rely on, localizedDescription either. “The operation couldn’t be completed” is the text for many unrelated errors. It reads like information and carries none.

Takeaway

The top-level error tells you what failed. The root tells you why. Write the user’s message from the root. And unwrap the full chain in your diagnostics, because that’s the only way you’ll find out that the message you’re showing is wrong.

Then be honest about the leftover case. When the chain doesn’t tell you the cause, say what the user can do, and leave the cause out.


I'm Kou (日々野 考), an indie iOS developer in Japan building on-device ML apps under kouworks. I write up the things that cost me days, so they cost you minutes.

Apps:

  • VoiceKit — filler-word removal, subtitles, and summarization, entirely on-device.
  • KidsMask — automatic face mosaic for photos and video; no face data ever leaves the device.
  • VlogMask — real-time face mosaic camera for vlogging.
  • VertiCut — turns horizontal video into vertical 9:16, following a person automatically.